Merge branch 'x10sa'
Build and Publish / build (push) Successful in 15s

# Conflicts:
#	common/pyproject.toml
#	daq/src/aaredaq/spreadsheetupdater.py
#	gui/pyproject.toml
#	pyproject.toml
#	src/aare/daq/tellupdater.py
This commit is contained in:
2026-03-10 13:36:10 +01:00
143 changed files with 9252 additions and 7262 deletions
+3 -169
View File
@@ -1,24 +1,17 @@
name: Build and Publish
on:
push:
tags:
- '*'
branches:
- master
- deploy
- 'feature/**'
- gitea-pages
pull_request:
branches:
- master
- deploy
pull_request_target:
types: [closed]
env:
DEPLOY_BRANCH: gitea-pages
PREVIEWS_DIR: previews
jobs:
build:
runs-on: ubuntu-latest
@@ -35,24 +28,8 @@ jobs:
- name: Build the common wheel
run: |
source venv/bin/activate
cd common/
python -m build
mv dist/* ../dist
- name: Build the GUI wheel
run: |
source venv/bin/activate
cd gui/
python -m build
mv dist/* ../dist
- name: Build the DAQ wheel
run: |
source venv/bin/activate
cd daq/
python -m build
mv dist/* ../dist
source venv/bin/activate
python -m build
- name: Upload Package to Gitea PyPI
if: github.ref == 'refs/heads/master' && contains(github.event.head_commit.message, 'build and publish')
@@ -63,146 +40,3 @@ jobs:
source venv/bin/activate
twine upload --repository-url https://gitea.psi.ch/api/packages/mx/pypi \
dist/*
docs:
name: Build and Deploy Docs
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for pushing branches/tags
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Build Sphinx HTML
working-directory: daq/docs
run: |
# ensure on master
git fetch origin master:master || true
git checkout master || true
# create venv one level up and activate it
python3 -m venv ../venv
source ../venv/bin/activate
# ensure pip and Sphinx are installed in this venv
pip install --upgrade pip
pip install "Sphinx==8.2.3"
pip install myst-parser
pip install sphinx_immaterial
pip install linkify-it-py
if [ -f requirements.txt ]; then
pip install -r requirements.txt
elif [ -f ../docs/requirements.txt ]; then
pip install -r ../docs/requirements.txt
else
# fallback to known packages if requirements file is not present
pip install "Sphinx==8.2.3" myst-parser sphinx_immaterial linkify-it-py
fi
# install project editable without deps (avoid private packages)
pip install -e ../ --no-deps
# prefer the sphinx-apidoc CLI if available; otherwise try module fallback
if command -v sphinx-apidoc > /dev/null 2>&1; then
sphinx-apidoc -o modules/ ../src --separate --module-first --force --remove-old
else
# module fallback: some Sphinx installs expose the module at sphinx.ext.apidoc
python -m sphinx.ext.apidoc -o modules/ ../src --separate --module-first --force --remove-old
fi
# Build HTML (source is '.' because working-directory is docs)
# Run sphinx-build and capture exit code and full output to help debugging if it fails.
sphinx-build -b html . _build/html || {
echo "=== Sphinx build failed with exit $?: showing build output and tree ==="
echo "Contents of docs/ after build attempt:"
ls -la
echo "Contents of daq/docs/_build (if any):"
ls -la _build || true
# If sphinx produced a build log file, print it
if [ -f sphinx-build.log ]; then
echo "---- sphinx-build.log ----"
cat sphinx-build.log
fi
# Exit with non-zero to fail the job (avoid deploying empty site)
exit 1
}
# Sanity check: ensure HTML output exists before continuing to deploy
if [ ! -d _build/html ] || [ -z "$(ls -A _build/html)" ]; then
echo "ERROR: daq/docs/_build/html does not exist or is empty after successful sphinx-build."
ls -la _build || true
exit 1
fi
- name: Deploy to gitea-pages branch (Gitea)
env:
GIT_USER: "Martin Appleby (Gitea)"
GIT_EMAIL: "martin.appleby@psi.ch"
DEPLOY_TOKEN: ${{ secrets.DOCS_DEPLOY_TOKEN }}
run: |
set -e
git config --global user.name "$GIT_USER"
git config --global user.email "$GIT_EMAIL"
# Deploy strategy: create/update branch gitea-pages and replace its contents
# with the generated daq/docs/_build/html. This keeps only the documentation
# on the gitea-pages branch.
# Confirm build output exists (relative to repository root)
BUILD_DIR="daq/docs/_build/html"
echo "Checking build output at: $BUILD_DIR"
if [ ! -d "$BUILD_DIR" ] || [ -z "$(ls -A "$BUILD_DIR")" ]; then
echo "ERROR: $BUILD_DIR does not exist or is empty"
echo "Listing docs/:"
ls -la docs || true
echo "Listing daq/docs/_build:"
ls -la daq/docs/_build || true
exit 1
fi
# Work in a temporary clone to avoid touching the runner workspace
TMPDIR=$(mktemp -d)
REPO_URL="https://$DEPLOY_TOKEN@gitea.psi.ch/${{ github.repository }}"
git clone "$REPO_URL" "$TMPDIR"
cd "$TMPDIR"
# Create or switch to orphan branch gitea-pages
if git rev-parse --verify --quiet gitea-pages >/dev/null; then
git checkout gitea-pages
# Make sure working tree matches branch (hard reset)
git fetch --prune origin gitea-pages || true
git reset --hard origin/gitea-pages || git reset --hard
else
git checkout --orphan gitea-pages
fi
# Remove all files tracked in the branch (leave .git)
git rm -rf . || true
# Also remove untracked files to ensure clean branch state
git clean -fdx || true
# Copy built HTML into the clone root
echo "Copying built HTML into branch root..."
(cd "${GITHUB_WORKSPACE}/${BUILD_DIR}" && tar -cf - .) | tar -xf - -C "$TMPDIR"
# Ensure index.html exists in destination as sanity check
if [ ! -f "$TMPDIR/index.html" ] && [ ! -f "$TMPDIR/index.htm" ]; then
# If site root index is not present, try to check subfolder
echo "Warning: no index.html found at branch root after copy. Listing files:"
ls -la "$TMPDIR" || true
fi
git add --all
if git diff --quiet --cached; then
echo "No changes to deploy"
exit 0
fi
COMMIT_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
git commit -m "docs: (${COMMIT_DATE}) | auto-update documentation from ${GITHUB_SHA}"
git push --force-with-lease origin gitea-pages
+69
View File
@@ -0,0 +1,69 @@
# Changelog
## 0.3.0
- Merge three repos into one, remove Ultralytics as dependency
- **DAQ/Server**:
- Workflow assertions to ensure scintillator is down when possible.
- Improved aerotech movement and position calculations.
- Implementation of camera auto-exposure and zoom-dependent settings.
- Added SAS-TT school demo temporary raster function.
- Enhanced status polling with robust error handling for `Tell` and `Smargon` communication.
- `ml_box` updated with beamline-specific URLs and model class types.
- Added separate authentication pathway for staff-only beamline recovery operations.
- Improved database message limits for omega (to 2 decimal places).
- Added screenshot to database from GUI; face detection sequence and beamline recovery updates.
- **GUI**:
- Major rework of `predict_subscriber` and prediction stream updates; corrected default ZMQ stream for ML box.
- Added FPS counter and sharpness monitoring for sample camera.
- Improved camera thread performance and authentication failure handling.
- Added X10SA specific camera and prediction configurations.
- New `display_status` for user-facing connection/error messages.
- Integrated `dev_help` panel with logs and error code search.
- Added framework for building GUI tutorials.
- Added alert banner for critical disconnection issues (server/Tell).
- New screenshot button and controls in sample camera GUI.
- Added SSE support for face detection panel updates during automation.
- Integrated sample position resync in recovery panel to reduce Tell polling.
- **TELL**:
- Major refactor: removed redundant code and added simulated client.
- Converted wait functions to use inbuilt pshell functionality.
- Added error handling and proxy support for connection failures.
- Fixed event names for mounting, and added missing events for gripper detection and motion sync.
- Refactored SSE connection handling for simplicity and robustness.
- **Common & Devices**:
- Added centralized `error_codes` and refactored exception handling to use FastAPI handlers.
- Expanded device support with new PVs (beamstop, scintillator, collimator, cryojet, etc.).
- Cleaned up `mx_lib` wait functions and `my_motor` class; added `clean_filename` helper.
- Improved Smargon connection exception handling.
- Updated ABR_POS_HOME configuration and added comments for cryo positions.
- Added image upload comments and improved authentication error messaging.
- **Infrastructure**:
- Updated dependencies (added `pshell`) and improved documentation autodeployment workflows.
- Refactored package structure and imports.
- Fixed `logger_config` file path expansion.
### 0.2.69
- check beamline state before running raster, rotation and automation
- lowered polling rate of daq status to improve GUI performance
- robot drys then parks after repeated mount failures
### 0.2.68:
- added check box to numberlineedits to switch between user input and spreadsheet
- re-enabled MLprediction
- fixed bug in gui where manual sample couldnt be created when run nubmer was negative.
- Combined beamline view now split vertically not horizontally
- New error handling in DAQ for robot mounting errors
- Error box pop ups in GUI when sample gripper detection error occurs
- Automation queue pauses if sample gripper detection error occurs multiple times to allow robot dry, if continues stops and wiats for user interverntion
- added ingest_scan to send rotation and screening result to db
### 0.2.67:
- added run number to sampleshortinfo
- ingest raster result, including beam center to database
- aerotech goes to mount postion for manual mount
- logger config correctly expands file path for logger location.
- smart_rotiation_panel fix - but will be mvoed to DAQ in future update
- Fluoresence scan can be saved to CSV with right click
- Wait for ready before sending Tell to dry
### 0.2.66:
- moved rastergridingestmodel and centreofmassmodel from models to raster_grid.py
- after raster load closest image to new centre
View File
-25
View File
@@ -1,25 +0,0 @@
[project]
name = "aaredaqlib"
version = "0.2.72"
description = "Libraries shared between AareDAQ and AareGUI"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"pydantic==2.11.4",
"numpy==2.2.5",
"jfjoch_client==1.0.0rc125",
]
[lint]
ignore = ["F401", "F541", "W503", "W504"]
[tool.uv.sources]
aaredb = { index = "psi"}
[[tool.uv.index]]
name = "psi"
url = "https://gitea.psi.ch/api/packages/mx/pypi/simple"
[build-system]
requires = ["setuptools>=75.6.0"]
build-backend = "setuptools.build_meta"
-29
View File
@@ -1,29 +0,0 @@
# Changelog
## 0.2.70
### 0.2.69
- check beamline state before running raster, rotation and automation
- lowered polling rate of daq status to improve GUI performance
- robot drys then parks after repeated mount failures
### 0.2.68:
- added check box to numberlineedits to switch between user input and spreadsheet
- re-enabled MLprediction
- fixed bug in gui where manual sample couldnt be created when run nubmer was negative.
- Combined beamline view now split vertically not horizontally
- New error handling in DAQ for robot mounting errors
- Error box pop ups in GUI when sample gripper detection error occurs
- Automation queue pauses if sample gripper detection error occurs multiple times to allow robot dry, if continues stops and wiats for user interverntion
- added ingest_scan to send rotation and screening result to db
### 0.2.67:
- added run number to sampleshortinfo
- ingest raster result, including beam center to database
- aerotech goes to mount postion for manual mount
- logger config correctly expands file path for logger location.
- smart_rotiation_panel fix - but will be mvoed to DAQ in future update
- Fluoresence scan can be saved to CSV with right click
- Wait for ready before sending Tell to dry
### 0.2.66:
- moved rastergridingestmodel and centreofmassmodel from models to raster_grid.py
- after raster load closest image to new centre
-149
View File
@@ -1,149 +0,0 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import os
import sys
# Add project root to Python path so autodoc can import modules
sys.path.insert(0, os.path.abspath('..'))
sys.path.insert(0, os.path.abspath('../src')) # <-- ensure 'src' is on path
sys.path.insert(0, os.path.abspath('../src/aaredaq'))
sys.path.insert(0, os.path.abspath('../src/mxlibs3'))
sys.path.insert(0, os.path.abspath('../../common'))
sys.path.insert(0, os.path.abspath('../../common/src'))
sys.path.insert(0, os.path.abspath('../../common/src/aaredaqlib'))
sys.path.insert(0, os.path.abspath('../../gui'))
sys.path.insert(0, os.path.abspath('../../gui/src'))
sys.path.insert(0, os.path.abspath('../../gui/src/aaregui'))
project = 'AareDAQ'
copyright = '2025, Paul Scherrer Institute'
author = 'M. Vears Appleby'
release = '0.2.70'
master_doc = 'index'
root_doc = 'index'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
autodoc_mock_imports = [
'cv2',
'redis',
'jfjoch_client',
'epics',
'sseclient',
'jwt',
'aaredb',
'aareDBclient',
'numpy',
'requests',
'PyYAML',
'loguru',
'utility_tools',
'extract_results',
'testxds',
'db_slurm_handler',
'websocket',
'dateutil',
'PySide6',
'scipy',
'redis_lock',
'fastapi',
'uvicorn',
'pydantic'
]
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
extensions = [
'myst_parser',
'sphinx_immaterial',
'sphinx.ext.autodoc', # Add this
'sphinx.ext.viewcode', # Optional: source code links
'sphinx.ext.napoleon' # Optional: Google/NumPy docstring support
]
templates_path = ['_templates']
exclude_patterns = []
myst_enable_extensions = ['linkify', 'smartquotes']
myst_heading_anchor = 3
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinx_immaterial'
# html_static_path = ['../broker/redoc-static.html']
#html_theme_options = {
# 'sidebar_width': '300px',
# 'page_width': 'auto'
#}
# html_theme_options = {
# 'repo_url': 'https://gitea.psi.ch/mx/aaredaq',
# 'repo_name': 'AareDaq',
# 'nav_title': 'PSI AareDaq',
# # 'html_minify': True,
# # 'css_minify': True,
# 'globaltoc_depth': 2,
# # 'repo_type': 'gitlab',
# 'color_primary': 'indigo',
# 'color_accent': 'lime',
# # 'logo_icon': '&#xe30d'
# }
# html_logo = 'jfjoch.png'
# html_favicon = 'jfjoch.png'
html_theme_options = {
"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",
"repo_name": "AareDAQ",
"palette": [
{
"media": "(prefers-color-scheme: light)",
"scheme": "default",
"primary": "indigo",
"accent": "lime",
"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",
},
},
],
"features": [
"navigation.expand",
"navigation.sections",
"navigation.top",
"search.share",
"toc.follow",
],
"globaltoc_collapse": False,
}
html_theme_options["google_fonts"] = []
html_sidebars = {
"**": ["logo-text.html", "globaltoc.html", "searchbox.html"]
}
html_show_sourcelink = False
# language = "Python"
-30
View File
@@ -1,30 +0,0 @@
.. AareProc documentation master file, created by
sphinx-quickstart on Sun Dec 21 14:22:05 2025.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to AareDAQ's documentation!
====================================
.. toctree::
:maxdepth: 2
:caption: Contents:
:glob:
README
CHANGELOG
.. toctree::
:maxdepth: 4
:caption: OpenAPI Python client
modules/src.aaredaq
modules/src.mxlibs3
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
-37
View File
@@ -1,37 +0,0 @@
alabaster==1.0.0
babel==2.16.0
beautifulsoup4==4.12.3
certifi==2024.8.30
charset-normalizer==3.4.0
css-html-js-minify==2.5.5
docutils==0.21.2
idna==3.10
imagesize==1.4.1
Jinja2==3.1.4
linkify-it-py==2.0.3
lxml==5.3.0
markdown-it-py==3.0.0
MarkupSafe==3.0.2
mdit-py-plugins==0.4.2
mdurl==0.1.2
myst-parser==4.0.0
packaging==24.2
Pygments==2.18.0
python-slugify==8.0.4
PyYAML==6.0.2
requests==2.32.3
snowballstemmer==2.2.0
soupsieve==2.6
Sphinx==8.1.3
sphinx-material==0.0.36
sphinxcontrib-applehelp==2.0.0
sphinxcontrib-devhelp==2.0.0
sphinxcontrib-htmlhelp==2.1.0
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==2.0.0
sphinxcontrib-serializinghtml==2.0.0
text-unidecode==1.3
uc-micro-py==1.0.3
Unidecode==1.3.8
urllib3==2.2.3
sphinx_immaterial==0.13.8
Binary file not shown.
-351
View File
@@ -1,351 +0,0 @@
import time
from epics import PV
from aaredaqlib.beamline import MXBeamline
from aaredaqlib.coordinate import Coordinate
from aaredaqlib.models import SampleCameraSettings, StagePositionEnum
from mxlibs3 import aerotech, smargon
from mxlibs3.area_detector import epicsAD
from mxlibs3.enum_pv import EnumPv
from mxlibs3.filter_transmission import FilterTransmission
from mxlibs3.fluorimeter import Fluorimeter
from mxlibs3.experimental_hutch_shutter import ExperimentalHutchShutter
from mxlibs3.my_motor import MyMotor
from mxlibs3.non_standard import NonStandard
from mxlibs3.tell_client import TellClient
from mxlibs3.workflow_tools import wait_position
class BeamlineDevices:
def __init__(self, beamline: MXBeamline):
#service_config = ServiceConfig(redis={"host": "x06da-bec-001.psi.ch", "port": 6379})
#client = BECClient(service_config, name="Filips-Custom-Client")
#client.start()
#self.__bec_dev = client.device_manager.devices
#self.__bec_scans = client.scans
BEAMLINE = beamline.value.upper()
self.tell = TellClient(beamline)
self.smargon = smargon.Smargon(beamline)
self.__energy_pv = PV(f"{BEAMLINE}-OP-DCCM:ENERGY1")
self.__ring_current_pv = PV(f"ARS07-DPCT-0100:CURR")
self.__cryojet_temp = PV(f"{BEAMLINE}-ES-CRSM:TEMP_RBV")
self.magnet_position_sensor = PV(f"{BEAMLINE}-ES-DF1:CBOX-CMP1")
self.magnet_position_sensor_readout = PV(f"{BEAMLINE}-ES-DF1:CBOX-USER1")
self.dtz = MyMotor(f"{BEAMLINE}-ES-DET:TRZ1")
# self.dty = MyMotor(f"{BEAMLINE}-MOCK-DET:TRY1")
self.bsz = MyMotor(f"{BEAMLINE}-ES-BS:TRZ1")
self.bsx = MyMotor(f"{BEAMLINE}-ES-BS:TRX1")
self.bsy = MyMotor(f"{BEAMLINE}-ES-BS:TRY1")
self.__bpm = PV(f"{BEAMLINE}-OP-XBPM1:SumAll:MeanValue_RBV")
self.__anneal = PV(f"{BEAMLINE}-ES-ANN:SET_POS")
self.aerotech = aerotech.Abr(beamline)
self.aerotech.reload_programs()
self.gmx = NonStandard(
name="GMX",
setpv=f"{BEAMLINE}-ES-DF1:GMX-VAL",
getpv=f"{BEAMLINE}-ES-DF1:GMX-RBV",
speed=(f"{BEAMLINE}-ES-DF1:GMX-SETV", f"{BEAMLINE}-ES-DF1:GMX-SETV"),
move_done_when=(f"{BEAMLINE}-ES-DF1:GMX-DONE", 1),
)
self.transmission = FilterTransmission(beamline)
self.gmy = NonStandard(
name="GMY",
setpv=f"{BEAMLINE}-ES-DF1:GMY-VAL",
getpv=f"{BEAMLINE}-ES-DF1:GMY-RBV",
speed=(f"{BEAMLINE}-ES-DF1:GMY-SETV", f"{BEAMLINE}-ES-DF1:GMY-SETV"),
move_done_when=(f"{BEAMLINE}-ES-DF1:GMY-DONE", 1),
)
self.gmz = NonStandard(
name="GMZ",
setpv=f"{BEAMLINE}-ES-DF1:GMZ-VAL",
getpv=f"{BEAMLINE}-ES-DF1:GMZ-RBV",
speed=(f"{BEAMLINE}-ES-DF1:GMZ-SETV", f"{BEAMLINE}-ES-DF1:GMZ-SETV"),
move_done_when=(f"{BEAMLINE}-ES-DF1:GMZ-DONE", 1),
)
self.__lamp_light = NonStandard(
name="FrontLight",
setpv=f"{BEAMLINE}-ES-FL:SET-BRGHT",
getpv=f"{BEAMLINE}-ES-FL:SET-BRGHT",
tolerance=0.01,
predefs={
"on": 2.5,
"half": 1.9,
"off": 1.0,
},
)
self.cryo = NonStandard(
name="CryoJet",
setpv=f"{BEAMLINE}-ES-CJ:TRX1",
getpv=f"{BEAMLINE}-ES-CJ:TRX1.RBV",
move_done_when=(f"{BEAMLINE}-ES-CJ:TRX1.DMOV", 1),
speed=(f"{BEAMLINE}-ES-CJ:TRX1.VELO", f"{BEAMLINE}-ES-CJ:TRX1.VELO"),
)
self.__zoom = NonStandard(
name="Zoom",
setpv=f"{BEAMLINE}-ES-SAMCAM:ZOOM.VAL",
getpv=f"{BEAMLINE}-ES-SAMCAM:ZOOM.RBV",
move_done_when=(f"{BEAMLINE}-ES-SAMCAM:ZOOM.DMOV", 1),
stoppv=(f"{BEAMLINE}-ES-SAMCAM:ZOOM.STOP", 1),
)
self.__shutter_rbv = PV(f"{BEAMLINE}-ES-PH1:GET")
self.__shutter = PV(f"{BEAMLINE}-ES-PH1:SET")
self.exp_shutter = ExperimentalHutchShutter(beamline)
# xrf_stage = EnumPv(
# {
# "name": "KetekStage",
# "pv": f"{BEAMLINE}-MOCK-FD:SET-POS",
# "readback": f"{BEAMLINE}-MOCK-FD:GET-POS",
# "states": {"park": "park", "measure": "measure"},
# }
# )
self.__reflector = EnumPv(
{
"name": "BackReflector",
"pv": f"{BEAMLINE}-ES-BL:SET-POS",
"readback": f"{BEAMLINE}-ES-BL:GET-POS",
"states": {
"park": "park",
"measure": "measure",
},
}
)
self.detector_cover = PV(f"{BEAMLINE}-ES-DETCOV:SET")
self.__beamstop_stage = EnumPv(
{
"name": "Beamstop_Stage",
"pv": f"{BEAMLINE}-ES-BS:SET-POS",
"readback": f"{BEAMLINE}-ES-BS:GET-POS",
"states": {
"park": "park",
"measure": "measure",
},
}
)
self.__collimator = EnumPv(
{
"name": "Collimator",
"pv": f"{BEAMLINE}-ES-COL:SET-POS",
"readback": f"{BEAMLINE}-ES-COL:GET-POS",
"states": {
"parking": "parking",
"measure": "measure",
"down": "down",
},
}
)
self.__scinti = EnumPv(
{
"name": "Scintillator",
"pv": f"{BEAMLINE}-ES-SCL:SET-POS",
"readback": f"{BEAMLINE}-ES-SCL:GET-POS",
"states": {
"parking": "parking",
"measure": "measure",
"down": "down",
},
}
)
self.sample_cam = epicsAD(f"{BEAMLINE}-SAMCAM:")
self.fluorimeter = Fluorimeter(beamline)
@property
def lamp_light(self) -> float:
return self.__lamp_light.value
@lamp_light.setter
def lamp_light(self, v: float):
self.__lamp_light.move(v, wait=False)
@property
def zoom(self) -> float:
return self.__zoom.value
@zoom.setter
def zoom(self, value: float):
self.__zoom.move(value, wait=False)
# status = self.__bec_dev.samzoom.move(value)
# status.wait()
def zoom_sync(self, value: float):
self.__zoom.move(value, wait=True)
@property
def collimator(self) -> StagePositionEnum:
if self.__collimator.position_is("down"):
return StagePositionEnum.DOWN
elif self.__collimator.position_is("measure"):
return StagePositionEnum.MEASURE
elif self.__collimator.position_is("parking"):
return StagePositionEnum.PARK
else:
return StagePositionEnum.UNKNOWN
@collimator.setter
def collimator(self, value: StagePositionEnum):
self.set_collimator(value, wait=True)
def set_collimator(self, value: StagePositionEnum, /, wait: bool = True):
if value == StagePositionEnum.DOWN:
self.__collimator.move("down", wait=wait)
elif value == StagePositionEnum.MEASURE:
self.__collimator.move("measure", wait=wait)
elif value == StagePositionEnum.PARK:
self.__collimator.move("parking", wait=wait)
@property
def scintillator(self) -> StagePositionEnum:
if self.__scinti.position_is("down"):
return StagePositionEnum.DOWN
elif self.__scinti.position_is("measure"):
return StagePositionEnum.MEASURE
elif self.__scinti.position_is("parking"):
return StagePositionEnum.PARK
else:
return StagePositionEnum.UNKNOWN
@scintillator.setter
def scintillator(self, value: StagePositionEnum):
self.set_scintillator(value, wait=True)
def set_scintillator(self, value: StagePositionEnum, /, wait: bool = True):
if value == StagePositionEnum.DOWN:
self.__scinti.move("down", wait=wait)
elif value == StagePositionEnum.MEASURE:
self.__scinti.move("measure", wait=wait)
elif value == StagePositionEnum.PARK:
self.__scinti.move("parking", wait=wait)
@property
def reflector_up(self) -> bool:
return bool(self.__reflector.position_is("measure"))
@reflector_up.setter
def reflector_up(self, value: bool):
if value:
self.__reflector.move("measure", wait=True)
else:
self.__reflector.move("park", wait=True)
@property
def beamstop_stage_up(self) -> bool:
return bool(self.__beamstop_stage.position_is("measure"))
@beamstop_stage_up.setter
def beamstop_stage_up(self, value: bool):
if value:
self.__beamstop_stage.move("measure", wait=True)
else:
self.__beamstop_stage.move("park", wait=True)
@property
def abr_pos(self) -> Coordinate:
return Coordinate(x=self.gmx.readback, y=self.gmy.readback, z=self.gmz.readback)
@abr_pos.setter
def abr_pos(self, pos: Coordinate):
self.gmx.move(pos.x, wait=True)
self.gmy.move(pos.y, wait=True)
self.gmz.move(pos.z, wait=True)
wait_position(self.gmx, pos.x, 0.005)
wait_position(self.gmy, pos.y, 0.005)
wait_position(self.gmz, pos.z, 0.005)
@property
def energy_kev(self) -> float:
return self.__energy_pv.value
@property
def ring_current(self) -> float:
return max(0.0, self.__ring_current_pv.value)
@property
def cryojet_temp(self) -> float:
return self.__cryojet_temp.value
def enable_motors(self):
self.aerotech.unlock()
self.dtz.refresh() # Why ??
self.bsz.refresh() # Why ??
def disable_motors(self):
self.aerotech.lock()
@property
def shutter(self) -> bool:
return self.__shutter_rbv.value == 1
@shutter.setter
def shutter(self, opened: bool):
if opened:
self.__shutter.put(1)
else:
self.__shutter.put(0)
@property
def samcam_settings(self) -> SampleCameraSettings:
return SampleCameraSettings(
gain=self.sample_cam.gain.value,
exposure=self.sample_cam.expo.value
)
@samcam_settings.setter
def samcam_settings(self, settings: SampleCameraSettings):
self.sample_cam.setup(settings.gain, settings.exposure)
@property
def flux(self) -> float:
# Very approximate value - to be changed
# 2 nA -> 4e11 ph/s
if self.transmission.get() is None:
return 0.0
return self.transmission.get() * self.full_flux
@property
def full_flux(self) -> float:
# Very approximate value - to be changed
# 2 nA -> 4e11 ph/s
return self.__bpm.value / 2.0 * 4e11
@property
def dtz_low(self) -> float:
return self.dtz.get("LLM")
@property
def dtz_high(self) -> float:
return self.dtz.get("HLM")
def anneal(self, value: float):
self.__anneal.put(1)
time.sleep(value)
self.__anneal.put(0)
-335
View File
@@ -1,335 +0,0 @@
import time
from epics import poll
from aaredaq.config import ABR_POS_MOUNT, ABR_OMEGA_MOUNT
from aaredaq.devices import BeamlineDevices
from aaredaq.config import BeamlineConfig
from aaredaqlib.coordinate import Coordinate
from aaredaqlib.logger_config import setup_logger
from aaredaqlib.models import StagePositionEnum, SampleCameraSettings, ZoomModeEnum
from mxlibs3.mx_lib import pv_wait
logger = setup_logger("aareDAQ")
def move_bsz(devs: BeamlineDevices, target: float):
if abs(target - devs.bsz.position) > 0.1:
beamstop_stage_measure = devs.beamstop_stage_up
reflector_measure = devs.reflector_up
devs.reflector_up = False
devs.beamstop_stage_up = True
devs.bsz.move(target, wait=True)
if reflector_measure:
devs.reflector_up = True
if not beamstop_stage_measure:
devs.beamstop_stage_up = False
def wait_for_dc_devices(devs: BeamlineDevices):
timeout = time.time() + 60
timeisup = False
# devs.detector_cover.wait()
while not timeisup and (
not devs.dtz.done_moving
):
poll(0.1)
timeisup = timeout < time.time()
if timeisup:
if not devs.dtz.done_moving:
logger.info("DTZ still moving.")
raise RuntimeError("Timeout moving devs. Dropping to maintenance.")
def wait_for_se_devices(devs: BeamlineDevices, cfg: BeamlineConfig):
hub = cfg.settings
hub_cryo = cfg.cryojet_settings
_cryo = hub_cryo.cryojet_in_use
_cryopark = hub_cryo.cryojet_park_position
if _cryo and not devs.cryo.position_is(_cryopark):
raise RuntimeError("Cryojet did not reach far position")
def common_2rse(devs: BeamlineDevices, cfg: BeamlineConfig):
logger.info(" moving COLLIMATOR to PARK position")
devs.collimator = StagePositionEnum.PARK
logger.info(" moving SMARGON to HOME position")
devs.smargon.move_home(wait=True)
logger.info(" moving AEROTECH to MOUNT position")
devs.aerotech.move(ABR_OMEGA_MOUNT, wait=True, direct=True)
devs.abr_pos = ABR_POS_MOUNT
logger.info(" moving REFLECTOR to DOWN position")
devs.reflector_up = False
logger.info(" LOCKING AEROTERCH")
devs.aerotech.lock()
logger.info(f" Smargon position in RSE: {devs.smargon.readback} ABS: {devs.abr_pos.x} {devs.abr_pos.y} {devs.abr_pos.z}")
def m2se(devs: BeamlineDevices, cfg: BeamlineConfig):
print("executing MAINTENANCE -> sample exchange")
devs.detector_cover.put(1) # Close detector cover
hub = cfg.settings
hub_cryo = cfg.cryojet_settings
cfg.zoom_mode = ZoomModeEnum.User
devs.samcam_settings = cfg.zoom_settings.get_camera_settings(devs.zoom)
devs.collimator = StagePositionEnum.PARK
devs.scintillator = StagePositionEnum.DOWN
_dtzpark = hub.dtz_park
_cryopark = hub_cryo.cryojet_park_position
_cryo = hub_cryo.cryojet_in_use
# devs.detector_cover.move("closed")
if not devs.aerotech.is_ready_and_willing():
devs.aerotech.stop()
time.sleep(5.0)
raise RuntimeError("Aerotech device is not ready")
if devs.dtz.position < _dtzpark:
devs.dtz.move(_dtzpark)
devs.gmx.set_speed(100.0)
devs.abr_pos = ABR_POS_MOUNT
devs.aerotech.move(ABR_OMEGA_MOUNT)
if _cryo:
devs.cryo.move(_cryopark)
devs.reflector_up = False
devs.bsx.move(0.0)
devs.bsy.move(0.0)
move_bsz(devs, hub.bsz)
devs.beamstop_stage_up = False
def sa2se(devs: BeamlineDevices, cfg: BeamlineConfig):
hub = cfg.settings
hub_cryo = cfg.cryojet_settings
logger.info(" moving cryojet to park position")
_cryopark = hub_cryo.cryojet_park_position
_dtzpark = hub.dtz_park
devs.aerotech.move(ABR_OMEGA_MOUNT)
if devs.cryo.position < _cryopark:
devs.cryo.move(_cryopark)
logger.info(" moving COLLIMATOR to DOWN position")
devs.collimator = StagePositionEnum.DOWN
logger.info(" moving REFLECTOR to DOWN position")
devs.reflector_up = False
devs.beamstop_stage_up = False
logger.info(" moving SMARGON to HOME position")
devs.smargon.move_home(wait=True)
logger.info(" moving DETECTOR COVER to DOWN position")
devs.detector_cover.put(1) # Close detector cover
if devs.dtz.position < _dtzpark:
logger.info(" moving DETECTOR to PARK position")
devs.dtz.move(_dtzpark)
def sa2rse(devs: BeamlineDevices, cfg: BeamlineConfig):
common_2rse(devs, cfg)
def dc2rse(devs: BeamlineDevices, cfg: BeamlineConfig):
common_2rse(devs, cfg)
def se2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
hub = cfg.settings
cfg.zoom_mode = ZoomModeEnum.User
devs.detector_cover.put(1)
devs.samcam_settings = cfg.zoom_settings.get_camera_settings(devs.zoom)
hub_cryo = cfg.cryojet_settings
_cryo = hub_cryo.cryojet_in_use
cryo_meas = hub_cryo.cryojet_measurement_position
_dtz_safety = hub.dtz_bsz_safety_margin
devs.aerotech.unlock()
logger.info("aerotech unlocked")
devs.aerotech.set_direct_mode()
logger.info("aerotech in direct mode")
logger.info(" moving reflector to up position")
devs.reflector_up = True
logger.info("moving beamstop to up position")
devs.beamstop_stage_up = True
logger.info(" setting lamp to 2.5")
devs.lamp_light = 2.5
logger.info(" moving aerotech to measure position")
devs.abr_pos = cfg.abr_meas_pos
if _cryo:
logger.info(" moving cryojet to measure position")
devs.cryo.move(cryo_meas)
logger.info("moving detector to measure position")
devs.dtz.move(cfg.dtz, wait=False)
def rse2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
se2sa(devs, cfg)
def sa2dc(devs: BeamlineDevices, cfg: BeamlineConfig):
devs.detector_cover.put(2) # Open detector cover
devs.reflector_up = False
#devs.reflector.wait("park")
devs.beamstop_stage_up = True
devs.dtz.move(cfg.dtz, wait=True)
devs.collimator = StagePositionEnum.MEASURE
pv_wait(devs.dtz, None, tolerance=1.0)
wait_for_dc_devices(devs)
def dc2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
hub = cfg.settings
devs.detector_cover.put(1) # Close detector cover
devs.collimator = StagePositionEnum.PARK
devs.reflector_up = True
# devs.gmz.move(cfg.abr_meas_pos.z, wait=True)
def sa2xrf(devs: BeamlineDevices, cfg: BeamlineConfig):
"""sample alignment to XrfCollection"""
pass
def xrf2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
"""XrfCollection to sample alignment"""
pass
def sa2ws(devs: BeamlineDevices, cfg: BeamlineConfig):
hub = cfg.settings
_dtzwash = hub.dtz_wash_sample_distance
devs.cryo.move(hub.cryojet_park_position)
if devs.dtz.position < _dtzwash:
devs.dtz.move(_dtzwash)
devs.reflector_up = False
devs.beamstop_stage_up = False
def ws2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
hub = cfg.settings
hub_cryo = cfg.cryojet_settings
cryo_meas = hub_cryo.cryojet_measurement_position
_cryo = hub_cryo.cryojet_in_use
devs.dtz.move(cfg.dtz)
if _cryo:
devs.cryo.move(cryo_meas)
devs.reflector_up = True
devs.beamstop_stage_up = True
def sa2ba(devs: BeamlineDevices, cfg: BeamlineConfig):
hub = cfg.settings
hub_cryo = cfg.cryojet_settings
_cryopark = hub_cryo.cryojet_park_position
_cryo = hub_cryo.cryojet_in_use
if _cryo:
devs.cryo.move(_cryopark)
devs.gmx.move(-38.0)
devs.lamp_light = 2.5
devs.beamstop_stage_up = True
move_bsz(devs, 0.0)
def ba2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
hub = cfg.settings
devs.lamp_light = 2.5
devs.beamstop_stage_up = True
move_bsz(devs, hub.bsz)
devs.reflector_up = True
devs.abr_pos = cfg.abr_meas_pos
def sa2bl(devs: BeamlineDevices, cfg: BeamlineConfig):
val=1000
devs.zoom = val
cfg.zoom_mode = ZoomModeEnum.BeamLocation
devs.detector_cover.put(1) # Close detector cover
devs.samcam_settings = cfg.zoom_settings.get_camera_settings(val)
devs.lamp_light = 2.5
devs.abr_pos = Coordinate(x=-26, y=0, z=0)
devs.cryo.move(80)
devs.set_scintillator(StagePositionEnum.MEASURE, wait=False)
devs.set_collimator(StagePositionEnum.MEASURE, wait=False)
devs.beamstop_stage_up = True
devs.reflector_up = False
devs.scintillator = StagePositionEnum.MEASURE
devs.collimator = StagePositionEnum.MEASURE
devs.shutter = True
def bl2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
hub = cfg.settings
hub_cryo = cfg.cryojet_settings
cryo_meas = hub_cryo.cryojet_measurement_position
_cryo = hub_cryo.cryojet_in_use
devs.shutter = False
devs.set_scintillator(StagePositionEnum.DOWN, wait=False)
devs.set_collimator(StagePositionEnum.PARK, wait=False)
devs.reflector_up = True
devs.scintillator = StagePositionEnum.DOWN
devs.collimator = StagePositionEnum.PARK
val=1
devs.zoom = val
cfg.zoom_mode = ZoomModeEnum.User
devs.samcam_settings = cfg.zoom_settings.get_camera_settings(val)
if _cryo:
devs.cryo.move(cryo_meas)
devs.abr_pos = cfg.abr_meas_pos
def bl2ba(devs: BeamlineDevices, cfg: BeamlineConfig):
"""beam location to beamstop alignment"""
devs.gmx.move(-38)
devs.reflector_up = True
# devs.shutter.move("closed")
time.sleep(0.5)
move_bsz(devs, 0)
def ba2bl(devs: BeamlineDevices, cfg: BeamlineConfig):
hub = cfg.settings
move_bsz(devs, hub.bsz)
devs.abr_pos = cfg.abr_meas_pos + Coordinate(x=1)
devs.reflector_up = True
def sa2dh(devs: BeamlineDevices, cfg: BeamlineConfig):
logger.info("moved to dewer transfer")
if devs.tell.get_mounted_sample() is not None:
try:
# Best effort try to unmount
devs.tell.unmount(wait=True)
except Exception as e:
print(f"Error for unmounting: {e}")
devs.tell.dry(wait_cold=-1, wait=False)
def dh2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
#devs.tell.move_cold(wait=True)
logger.info("return to sample alignment")
File diff suppressed because it is too large Load Diff
-126
View File
@@ -1,126 +0,0 @@
from typing import cast
from epics import PV
from .mx_lib import ValueWaitTimeout, pv_wait
class EnumPv(object):
def __init__(self, args):
mandatory_args = {"pv", "readback", "states"}
supplied_args = set(args.keys())
if not mandatory_args.issubset(supplied_args):
raise RuntimeError("Missing arguments: %s" % (", ".join(mandatory_args.difference(supplied_args))))
if type(args["states"]) is not dict:
raise RuntimeError("states must be a dictionary mapping put/readback for waiting purposes")
self.val = PV(args["pv"])
self.rbv = PV(args["readback"])
if "name" in args:
self.device_name = args["name"]
else:
self.device_name = self.val.pvname
if not hasattr(self.val, "enum_strs"):
raise RuntimeError("PV %s does not have enum_strs" % self.val.pvname)
if not hasattr(self.rbv, "enum_strs"):
raise RuntimeError("PV %s does not have enum_strs" % self.rbv.pvname)
self.__target = None
p = args.get("move_done_when")
if p is not None:
self.__wait_pv = PV(p[0])
self.__wait_target = p[1]
else:
self.__wait_pv = None
self.__wait_target = None
self.states = {}
for k, v in list(args["states"].items()):
self.states[k.upper()] = v.upper()
self.val_enums = [x.upper() for x in self.val.enum_strs] # type: ignore
self.rbv_enums = [x.upper() for x in self.rbv.enum_strs] # type: ignore
if "timeout" not in args:
self.__timeout = 60 # seconds
else:
self.__timeout = args["timeout"]
def __str__(self):
return f"<{self.device_name} at {self.position}>"
def __repr__(self):
s = (
f"<{self.device_name} at {self.position} an {self.__class__.__name__} "
f"instance at {hex(id(self))} positions = {self.positions}>"
)
return s
def has_position(self, position):
return position.upper() in self.states
@property
def positions(self):
return self.rbv_enums
def __position(self) -> str:
return self.rbv.enum_strs[self.rbv.get()] # type: ignore
position = property(__position)
def position_is(self, position) -> bool:
if type(position) in (int, float):
return int(position) == self.get()
else:
return str(position).upper() == str(self.__position()).upper()
def get(self, req_type=None) -> str | int:
if req_type:
res = self.position
else:
res = cast(int, self.rbv.get())
return res
def put(self, value, wait=False) -> None:
self.val.put(value)
self.__target = self.rbv_enums.index(self.states[self.val_enums[value]])
if wait:
self.wait()
def move(self, position, wait=False) -> None:
if type(position) is not int:
if not self.has_position(position):
raise ValueError(f"Invalid position {position} for {self.device_name}")
target = self.val_enums.index(position.upper())
else:
target = position
self.put(target, wait=wait)
def equal_dbr_string(self, val) -> bool:
value = self.rbv.get(as_string=True)
return cast(str, value).lower() == val.lower()
def equal_dbr_int(self, val) -> bool:
return val == self.rbv.get()
def wait(self, target=None):
if target is None:
target = self.__target
if self.__wait_pv:
pv = self.__wait_pv
else:
pv = self.rbv
if target is None:
target = self.__wait_target
try:
pv_wait(pv, target, timeout=self.__timeout, verbose=True)
except ValueWaitTimeout:
raise ValueWaitTimeout(f"Timeout waiting for device to reach [{target}], currently at [{self.position}]")
-53
View File
@@ -1,53 +0,0 @@
import time
from epics import Motor
class MyMotor(Motor):
def __init__(self, name):
super().__init__(name.upper())
@property
def speed(self):
return self.slew_speed
@speed.setter
def speed(self, speed):
self.slew_speed = speed
@property
def pvname(self):
return self._prefix[:-1]
def __readback(self):
return self.readback
position = property(__readback)
def __value(self):
return self.drive
value = property(__value)
class BogusMotor:
def __init__(self, name, **kwargs):
self.name = name
self.readback = kwargs.get("readback", 0.0)
self.target = kwargs.get("target", 0.0)
self.velo = kwargs.get("velo", 10.0)
@property
def position(self):
return self.readback
def move(self, target, wait=False):
if wait:
time.sleep(abs(target - self.readback) / self.velo)
self.readback = target
def refresh(self):
pass
def get_position(self, *args, **kwargs):
return self.readback
-285
View File
@@ -1,285 +0,0 @@
import time
from typing import Callable, cast
from epics import PV, poll
from epics.ca import pend_io
def is_number(x):
return isinstance(x, (int, float))
class NonStandard(object):
def __init__(self, **kwargs):
mandatory = ["name", "setpv", "getpv"]
if set(mandatory) != set(kwargs.keys()) & set(mandatory):
raise RuntimeError(
"missing mandatory argument(s): {}".format(str(list(set(mandatory) - set(kwargs.keys()))))
)
self.name = kwargs.get("name")
self.__target_pos: float | int | str = 0
self.device_name = self.name
self.__setpv = PV(kwargs["setpv"])
self.__getpv = PV(kwargs["getpv"])
self.__timeout_margin = kwargs.get("timeout", 2.0) # add this safety margin to timeout calc from speed
self.__timeout = kwargs.get("timeout", 60.0)
self.__format = kwargs.get("format")
p = kwargs.get("speed")
if p is not None:
if is_number(p):
self.__speed_set, self.__speed_get = p, p
elif type(p) in [tuple, list] and len(p) == 2:
self.__speed_set = PV(p[0])
self.__speed_get = PV(p[1])
else:
raise Exception(f"{self.name} => don't know how to parse speed parameter")
else:
self.__speed_set, self.__speed_get = None, None
#
# move_done_when a tuple, first item the pv to read
# second item, the int type indicating that it is DONE
#
p = kwargs.get("move_done_when")
if p is not None:
self.__move_flag = PV(p[0])
self.__move_flag_done = p[1]
else:
self.__move_flag = None
#
# stoppv a tuple, first item the pv to read
# second item, the int type to put to stop the motor
#
p = kwargs.get("stoppv")
if p is not None:
self.__stoppv = PV(p[0])
self.__stoppv_done = p[1]
else:
self.__stoppv = None
#
# still_moving_when a tuple, first item the pv to read
# second item, the int type indicating that it is still moving.
#
p = kwargs.get("still_moving_when")
if p is not None:
self.__still_moving_flag = PV(p[0])
self.__still_moving_flag_value = p[1]
else:
self.__still_moving_flag = None
p = kwargs.get("predefs")
if p:
self.__predefs = p
self.positions = list(p.keys())
else:
self.__predefs = {}
self.positions = []
pend_io(5.0)
typ = self.__ret_type = self.__getpv.type
match str(typ):
case "double" | "time_double":
self.still_moving = self.__moving_float
case "int" | "time_int":
self.still_moving = self.__moving_int
case "string" | "time_string":
self.still_moving = self.__moving_str
case _:
self.still_moving = self.__moving_unknown
tolerance = kwargs.get("tolerance", None)
if tolerance is not None and tolerance > 0:
self.tolerance = tolerance
elif tolerance is not None and tolerance < 0:
self.still_moving = self.__moving_unknown
if self.__move_flag:
self.still_moving = self.is_move_not_done
elif self.__still_moving_flag:
self.still_moving = self.still_moving_when
def __str__(self):
if self.positions:
return "<{} at {}({}) Pre-defined positions {}>".format(
self.name, self.__position(predef=True), self.position, self.positions
)
else:
return "<{} at {}>".format(self.name, self.__position())
def __repr__(self):
if self.positions:
return "<{} instance at {}: {} at {}({}) Pre-defined positions {}>".format(
self.__class__.__name__,
hex(id(self)),
self.name,
self.__position(predef=True),
self.position,
self.positions,
)
else:
return "<{} instance at {}: {} at {}>".format(
self.__class__.__name__, hex(id(self)), self.name, self.__position()
)
def still_moving_when(self):
return self.__still_moving_flag_value == self.__still_moving_flag.get() # type: ignore
def is_moving(self):
return not self.is_move_done()
def is_move_done(self):
return self.__move_flag_done == self.__move_flag.get() # type: ignore
def is_move_not_done(self):
return not self.is_move_done()
def __moving_float(self):
if type(self.__target_pos) is not float:
raise RuntimeError("target position is not an float")
current_value = cast(float, self.__getpv.get())
return self.tolerance < abs(cast(float, self.__target_pos) - current_value)
def __moving_int(self):
if type(self.__target_pos) is not int:
raise RuntimeError("target position is not an integer")
return self.__target_pos != self.__getpv.get()
def __moving_str(self):
if type(self.__target_pos) is not str:
raise RuntimeError("target position is not a string")
current_value = cast(str, self.__getpv.get())
return self.__target_pos.upper() != current_value.upper()
def __moving_unknown(self):
return False
def __position(self, predef: bool = False):
"""returns either a predefined position or a readback
:rtype: Union[str, float, int]
"""
cur = self.readback
if not predef:
return cur
# loop over predefined positions, if one found and
# readback matches it's value return "position"
for pos_label, val_or_cbk in self.__predefs.items():
if self.__ret_type is bytes:
tst = val_or_cbk == cur
elif type(val_or_cbk) is tuple:
# a tuple (Callable, arg-to-Callable)
val = val_or_cbk[0](*val_or_cbk[1])
tst = 0.1 > abs(val - cur) # FIXME sooo bad
else:
tst = 0.1 > abs(val_or_cbk - cur)
if tst:
return pos_label
return "unknown"
def __readback(self):
return self.__getpv.get()
readback = property(__readback)
position = property(__position)
def has_position(self, position: str) -> bool:
return position.lower() in [p.lower() for p in self.positions]
def position_is(self, position, tolerance=0.1):
pos = self.__predefs.get(position, position)
cur = self.position
print(f"position_is: {pos} == {cur}")
if type(pos) is tuple:
pos = pos[0](*pos[1])
print(f"position_is: {pos} == {cur}")
if self.__ret_type is bytes:
print("checking bytes")
return pos == cur
else:
print(f"checking numbers: abs({pos} - {cur}) < {tolerance}")
return abs(pos - cur) < tolerance
def __value(self):
val = self.__getpv.get()
if self.__format is not None:
val = self.__format % (val,)
return val
value = property(__value)
def __raw_value(self):
return self.__getpv.get()
raw_value = property(__raw_value)
def __char_value(self):
return str(self.value)
char_value = property(__char_value)
def set_speed(self, speed):
if isinstance(self.__speed_set, PV):
self.__speed_set.put(speed)
elif is_number(self.__speed_set):
self.__speed_set = speed
self.__speed_get = speed
def get_speed(self):
if is_number(self.__speed_get):
return self.__speed_get
elif isinstance(self.__speed_get, PV):
return self.__speed_get.value
else:
return None
def get_timeout(self):
if not self.__speed_get:
timeout = self.__timeout
else:
speed = self.get_speed()
curp = self.position
if curp is None or self.__target_pos is None or speed is None:
timeout = self.__timeout_margin
raise RuntimeWarning("can't calculate timeout, ABR may be disconnected")
else:
timeout = self.__timeout_margin + (abs(curp - self.__target_pos) / speed)
return timeout
def wait(self):
pos = self.__target_pos
timeout = time.time() + self.get_timeout()
while time.time() < timeout and self.still_moving():
poll(0.1)
if time.time() > timeout:
msg = "timeout occurred when moving %s to %s" % (self.name, pos)
print(msg)
raise RuntimeWarning(msg)
def move(self, pos, relative=False, wait=False):
pos = self.__predefs.get(pos, pos)
if type(pos) is tuple:
pos = pos[0](*pos[1])
if relative:
pos += self.__getpv.get()
self.__target_pos = pos
self.__setpv.put(pos)
if wait:
time.sleep(0.1)
self.wait()
def stop(self):
if self.__stoppv:
self.__stoppv.put(self.__stoppv_done)
else:
raise RuntimeError("stop pv not configured for this motor")
-794
View File
@@ -1,794 +0,0 @@
import threading
import time
import sys
import requests
import json
try:
from urllib import quote # Python 2
except ImportError:
from urllib.parse import quote # Python 3
class TimeoutException(Exception):
pass
try:
from sseclient import SSEClient
except:
SSEClient = None
class SSEReceiver:
def __init__(self, url, subscribed_events):
if SSEClient is None:
raise Exception("sseclient library is not installed: server events are not available")
self.url = url
self.events = subscribed_events
self._lock = threading.Lock()
self._stop = threading.Event()
self.session = None
self.client = None
self.debug = False
self._subscribers = {}
self.thread = threading.Thread(target=self.task, kwargs={})
self.thread.daemon = True
self.thread.start()
def task(self):
try:
while not self._stop.is_set():
try:
self.session = requests.Session()
self.client = SSEClient(self.url, session=self.session)
for msg in self.client:
if self.is_closed():
break
event_name = msg.event or "message"
if (self.events is None) or (event_name in self.events):
try:
data = json.loads(msg.data)
except:
data = str(msg.data)
#if self.debug:
# print (event_name, data)
with self._lock:
subs = list(self._subscribers.values())
for events, callback in subs:
if events is None or event_name in events:
try:
callback(event_name, data)
except Exception as e:
if self.debug:
print(f"[SSEManager] Error in callback {callback}: {e}")
except IOError as e:
# print(e)
pass
except:
if self.debug:
print("Error:", sys.exc_info()[1])
finally:
self._close_client()
if self.is_closed():
break
else:
time.sleep(1.0)
finally:
if self.debug:
print("Exit SSE loop task")
def subscribe(self, callback, events=None):
"""
Subscribe to SSE events.
Args:
callback: function(event_name, data)
events: None (all events), str (one event), or list[str] (multiple events)
"""
if isinstance(events, str):
events = [events]
if events is not None:
events = set(events)
with self._lock:
self._subscribers[id(callback)] = (events, callback)
def unsubscribe(self, callback):
"""Unsubscribe a previously subscribed callback."""
with self._lock:
self._subscribers.pop(id(callback), None)
def wait_events(self, events={}, timeout=-1):
"""Wait any of the events matching the value (value None for any).
Args:
events (dict event name->value)
timeout:
Returns:
(event, value) or None if timeout
"""
rx = {}
condition = threading.Condition()
def callback(name, value):
with condition:
rx[name] = value
condition.notify_all()
self.subscribe(callback, events.keys())
try:
start = time.time()
with condition:
while True:
for name in events.keys():
if name in rx.keys():
values, rx_value = events[name], rx[name]
if values is not None and type(values) is not list:
values = [values]
if values is None or rx_value in values:
return name,rx_value
remaining = None
if timeout >= 0:
remaining = max(0, timeout - (time.time() - start))
if remaining <= 0:
return None
condition.wait(timeout=remaining)
finally:
self.unsubscribe(callback)
def _close_client(self):
self.client = None
"""
if self.client is not None:
try:
if hasattr(self.client.resp, "raw"):
conn = getattr(self.client.resp.raw, "_connection", None)
if conn and hasattr(conn, "sock") and conn.sock:
conn.sock.shutdown(2)
conn.sock.close()
self.client.resp.close()
self.client = None
except:
pass
"""
if self.session is not None:
try:
self.session.close()
self.session = None
except:
pass
def close(self):
self._stop.set()
self._close_client()
if self.debug:
print("closed")
def is_closed(self):
return self._stop.is_set()
class PShellClient:
def __init__(self, url):
if not url.endswith('/'):
url = url + "/"
self.url = url
self.sse_event_loop_thread = None
self.sse_client = None
self.plot_defaults = {"format": "png", "width": 600, "height": 400}
self.debug = False
self.polling_interval = 0.1
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def _get(self, url, stream=False):
url = self.url + url
if self.debug:
print("GET " + url)
return requests.get(url=url, stream=stream)
def _put(self, url, json_data=None):
url = self.url + url
if self.debug:
print("PUT " + url + " -> " + json.dumps(json_data))
return requests.put(url=url, json=json_data)
def _del(self, url):
url = self.url + url
if self.debug:
print("DEL " + url)
return requests.delete(url=url)
def _get_response(self, response, is_json=True):
if self.debug == True or self.debug == "rx":
print(" -> " + str(response.status_code) + ((" - " + response.text) if self.debug == "rx" else ""))
try:
response.raise_for_status()
except:
print(response.text)
raise
return json.loads(response.text) if is_json else response.text
def _get_binary_response(self, response):
response.raise_for_status()
return response.raw.read()
def get_plot_defaults(self):
"""Return plot default properties.
Args:
Returns:
Dictionary
"""
return self.plot_defaults.copy()
def set_plot_defaults(self, defaults):
"""Update plot default properties.
Args:
Dictionary that will updated into the plot default properties.
Returns:
"""
self.plot_defaults.update(defaults)
def get_version(self):
"""Return application version.
Args:
Returns:
String with application version.
"""
return self._get_response(self._get("version"), False)
def get_config(self):
"""Return application configuration.
Args:
Returns:
Dictionary.
"""
return self._get_response(self._get("config"))
def get_state(self):
"""Return application state.
Args:
Returns:
String: Invalid, Initializing,Ready, Paused, Busy, Disabled, Closing, Fault, Offline
"""
return self._get_response(self._get("state"))
def wait_state(self, state, timeout=-1):
"""Wait application state equals.
Args:
state (string or list of strings)
timeout(number) wait timeout in seconds. If less or equal 0 then wait forever.
Returns:
"""
if type(state) == str:
state = [state]
start = time.time()
while self.get_state() not in state:
if (timeout >= 0) and ((time.time() - start) > timeout):
raise TimeoutException(f"Timeout waiting state {state}")
time.sleep(self.polling_interval)
def wait_state_not(self, state, timeout=-1):
"""Wait application state different than.
Args:
state (string or list of strings)
timeout(number) wait timeout in seconds. If less or equal 0 then wait forever.
Returns:
"""
if type(state) == str:
state = [state]
start = time.time()
while self.get_state() in state:
if (timeout >= 0) and ((time.time() - start) > timeout):
raise TimeoutException(f"Timeout waiting state not {state}")
time.sleep(self.polling_interval)
def get_logs(self):
"""Return application logs.
Args:
Returns:
List of logs.
Format of each log: [date, time, origin, level, description]
"""
return self._get_response(self._get("logs"))
def get_history(self, index):
"""Access console command history.
Args:
index(int): Index of history entry (0 is the most recent)
Returns:
History entry
"""
return self._get_response(self._get("history/" + str(index)), False)
def get_script(self, path):
"""Return script.
Args:
path(str): Script path (absolute or relative to script folder)
Returns:
String with file contents.
"""
return self._get_response(self._get("script/" + str(path)), False)
def get_devices(self):
"""Return global devices.
Args:
Returns:
List of devices.
Format of each device record: [name, type, state, value, age]
"""
return self._get_response(self._get("devices"))
def abort(self, command_id=None):
"""Abort execution of command
Args:
command_id(optional, int): id of the command to be aborted.
if None (default), aborts the foreground execution.
Returns:
"""
if command_id is None:
self._get("abort")
else:
return self._get("abort/" + str(command_id))
def pause(self):
"""Pause execution of command
Args:
Returns:
"""
self._get("pause")
def resume(self):
"""Resume execution of command
Args:
Returns:
"""
self._get("resume")
def reinit(self):
"""Reinitialize the software.
Args:
Returns:
"""
self._get("reinit")
def stop(self):
"""Stop all devices implementing the 'Stoppable' interface.
Args:
Returns:
"""
self._get("stop")
def update(self):
"""Update all global devices.
Args:
Returns:
"""
self._get("update")
def eval(self, statement):
"""Evaluates a statement in the interpreter.
If the statement finishes by '&', it is executed in background.
Otherwise statement is executed in foreground (exclusive).
Args:
statement(str): input statement
Returns:
String containing the console return.
If an exception is produces in the interpretor, it is re-thrown here.
"""
statement = quote(statement)
return self._get_response(self._get("eval/" + statement), False)
def run(self, script, pars=None, background=False):
"""Executes script in the interpreter.
Args:
script(str): name of the script (absolute or relative to the script base folder). Extension may be omitted.
pars(optional, list or dict): if a list is given, it sets sys.argv for the script.
If a dict is given, it sets global variable for the script.
background(optional, bool): if True script is executed in background.
Returns:
Return value of the script.
If an exception is produces in the interpretor, it is re-thrown here.
"""
return self._get_response(
self._put("run", {"script": script, "pars": pars, "background": background, "async": False}))
def start_eval(self, statement):
"""Starts evaluation of a statement in the interpreter.
If the statement finishes by '&', it is executed in background.
Otherwise statement is executed in foreground (exclusive).
Args:
statement(str): input statement
Returns:
Command id (int), which is used to retrieve command execution status/result (get_result).
"""
statement = quote(statement)
return int(self._get_response(self._get("evalAsync/" + statement), False))
def eval_json(self, statement):
"""Evaluates a statement in the interpreter.
Args:
statement(str): input statement
Returns:
Return object decoded from JSON string
"""
statement = quote(statement)
return self._get_response(self._get("eval-json/" + statement), True)
def eval_then(self, statement, on_success=True, on_exception=True):
"""Set a next execution stage for the interpreter - the statement is executed
after the foreground task concludes, keeping application state busy.
Args:
statement(str): statement for next execution stage
on_successs(bool): statement is executed if foreground task completes successfully.
on_exception(bool): statement is executed if foreground task throws exception.
"""
return self._get_response(
self._put("then", {"statement": statement, "onSuccess": on_success, "onException": on_exception}))
def run_then(self, script, pars=None, on_success=True, on_exception=True):
"""Set a next execution stage for the interpreter - the script is executed
after the foreground task concludes, keeping application state busy.
Args:
script(str): name of the script (absolute or relative to the script base folder). Extension may be omitted.
pars(optional, list or dict): if a list is given, it sets sys.argv for the script.
If a dict is given, it sets global variable for the script.
on_successs(bool): statement is executed if foreground task completes successfully.
on_exception(bool): statement is executed if foreground task throws exception.
"""
cmd = f"run('{script}', {pars})"
return self.eval_then(cmd, on_success, on_exception)
def set_var(self, name, value):
"""Sets interpreter variable.
Args:
name(str): variable name
value(obj): value - must be JSON compatible
Returns:
"""
data = {}
data["name"] = name
data["value"] = value
return self._get_response(self._put("set-var", data), False)
def start_run(self, script, pars=None, background=False):
"""Starts execution of a script in the interpreter.
Args:
script(str): name of the script (absolute or relative to the script base folder). Extension may be omitted.
pars(optional, list or dict): if a list is given, it sets sys.argv for the script.
If a dict is given, it sets global variable for the script.
background(optional, bool): if True script is executed in background.
Returns:
Command id (int), which is used to retrieve command execution status/result (get_result).
"""
return int(self._get_response(
self._put("run", {"script": script, "pars": pars, "background": background, "async": True})))
def get_result(self, command_id=-1):
"""Gets status/result of a command executed asynchronously (start_eval and start_run).
Args:
command_id(optional, int): command id. If equals to -1 (default) return status/result of the foreground task.
Returns:
Dictionary with the fields: 'id' (int): command id
'status' (str): unlaunched, invalid, removed, running, aborted, failed or completed.
'exception' (str): if status equals 'failed', holds exception string.
'return' (obj): if status equals 'completed', holds return value of script (start_run)
or console return (start_eval)
"""
return self._get_response(self._get("result/" + str(command_id)))
def help(self, input="<builtins>"):
"""Returns help or auto-completion strings.
Args:
input(optional, str): - ":" for control commands
- "<builtins>" for builtin functions
- "devices" for device names
- builtin function name for function help
- else contains entry for auto-completion
Returns:
List
"""
return self._get_response(self._get("autocompletion/" + input))
def get_contents(self, path=None):
"""Returns contents of data path.
Args:
path(optional, str): Path to data relative to data home path.
- Folder
- File
- File (data root) | internal path
- internal path (on currently open data root)
Returns:
List of contents
"""
return self._get_response(self._get("contents" + ("" if path is None else ("/" + path))), False)
def get_data(self, path, type="txt"):
"""Returns data on a given path.
Args:
path(str): Path to data relative to data home path.
- File (data root) | internal path
- internal path (on currently open data root)
type(optional, str): txt, "json", "bin", "bs"
Returns:
Data accordind to selected format/.
"""
if type == "json":
return self._get_response(self._get("data-json/" + path), True)
elif type == "bin":
return self._get_binary_response(self._get("data-bin/" + path, stream=True))
elif type == "bs":
from collections import OrderedDict
bs = self._get_binary_response(self._get("data-bs/" + path, stream=True))
index = 0
msg = []
for i in range(4):
size = int.from_bytes(bs[index:index + 4], byteorder='big', signed=False)
index = index + 4
msg.append(bs[index:index + size])
index = index + size
[main_header, data_header, data, timestamp] = msg
main_header = json.loads(main_header, object_pairs_hook=OrderedDict)
data_header = json.loads(data_header, object_pairs_hook=OrderedDict)
channel = data_header["channels"][0]
channel["encoding"] = "<" if channel.get("encoding", "little") else ">"
from bsread.data.helpers import get_channel_reader
channel_value_reader = get_channel_reader(channel)
return channel_value_reader(data)
return self._get_response(self._get("data" + ("" if path is None else ("/" + path))), False)
def get_data_attrs(self, path):
return self._get_response(self._get("data-attr/" + path), True)
def get_data_info(self, path):
return self._get_response(self._get("data-info/" + path), True)
def get_scan_data(self, layout, path, group, device, type="txt"):
"""Returns scan data of a device.
Args:
layout(str): data layout
path(str): scan path
group(str): scan group
device(str): device name
type(optional, str): txt, "json", "bin"
Returns:
Data accordind to selected format.
"""
if layout is None or layout.strip() == "" or path is None:
raise Exception("Invalid scan persistence path or layout")
path = path.replace("/", "<br>")
path = path.replace("|", "<p>")
group = group.replace("/", "<br>")
layout = layout.replace(".", "<br>")
url = layout + "/" + path + "/" + group + "/" + device
if type == "json":
url = "scandata-json/" + url
return self._get_response(self._get(url), True)
elif type == "bin":
url = "scandata-bin/" + url
return self._get_binary_response(self._get(url, stream=True))
url = "scandata/" + url
return self._get_response(self._get(url), False)
def get_plot_contexts(self):
"""Return list of plot contexts
Args:
Returns:
List of names
"""
return self._get_response(self._get("plots"))
def delete_plot_context(self, title):
"""
Delete a plotting context.
Args:
title(str): name of the plotting context
Returns:
"""
return self._get_response(self._del("plots/" + title), False)
def get_num_plots(self, title=None):
"""Return number of plots in a given plotting context.
Args:
title(str): name of the plotting context
Returns:
Number of plots
"""
if title is None:
title = "null"
return int(self._get_response(self._get("plots/" + title)))
def get_plot(self, title=None, index=0, format="png", width=None, height=None):
"""Return a plot as a given image type.
Args:
title(str): name of the plotting context
index(int): plot index (0-based)
format(str): plot format ("jpg", "png", "gif", "tif")
width(int): plot width (if 0 gets plot staddard size)
height(int): plot height (if 0 gets plot staddard size)
Returns:
Image file byte array
"""
if title is None:
title = "null"
if format is None:
format = self.plot_defaults["format"]
if width is None:
width = self.plot_defaults["width"]
if height is None:
height = self.plot_defaults["height"]
url = "plot/" + title + "/" + str(index) + "/" + format + "/" + str(width) + "/" + str(height)
return self._get_binary_response(self._get(url, stream=True))
def print_logs(self):
for l in self.get_logs():
print("%s %s %-20s %-8s %s" % tuple(l))
def print_devices(self):
for l in self.get_devices():
print("%-16s %-32s %-10s %-32s %s" % tuple(l))
def print_help(self, input="<builtins>"):
for l in self.help(input):
print(l)
def _get_sse(self):
if self.sse_client is None:
self.sse_client = SSEReceiver(self.url + "events", None)
self.sse_client.debug = self.debug
return self.sse_client
def subscribe(self, callback=None, events=None):
"""
Subscribe to SSE events.
Args:
callback: function(event_name, data), If None, calls self.on_event
events: None (all events), str (one event), or list[str] (multiple events)
Usage example:
def on_event(name, value):
if name == "state":
print ("State changed: ", value)
elif name == "record":
print ("Received scan record: ", value)
pc.subscribe(["state", "record"], on_event)
"""
if callback == None:
callback = self.on_event
self._get_sse().subscribe(callback, events)
def unsubscribe(self, callback):
"""Unsubscribe a previously subscribed callback."""
self._get_sse().unsubscribe(callback)
def wait_events(self, events={}, timeout=-1):
"""Wait any of the events matching the value (value None for any).
Args:
events (dict event name->value)
timeout(number) wait timeout in seconds. If less or equal 0 then wait forever.
Returns:
(event, value) or raises TimeoutException
Usage example:
def on_event(name, value):
if name == "state":
print ("State changed: ", value)
elif name == "record":
print ("Received scan record: ", value)
pc.subscribe(["state", "record"], on_event)
"""
ret = self._get_sse().wait_events(events, timeout)
if ret is None:
raise TimeoutException(f"Timeout waiting for events {events}")
return ret
def on_event(self, name, value):
"""
Default event callback
Args:
name: event name.
value: event value.
"""
pass
def close(self):
if self.sse_client is not None:
self.sse_client.close()
-132
View File
@@ -1,132 +0,0 @@
from enum import Enum
from time import sleep, time
import requests
from aaredaqlib.beamline import MXBeamline
from aaredaqlib.coordinate import SmargonCoordinate, Coordinate
class SmargonMode(Enum):
UNINITIALIZED = 0
INITIALIZING = 1
READY = 2
ERROR = 99
class Smargon(object):
SMARGON_HOME = SmargonCoordinate(
sh_mm=Coordinate(x=0, y=0, z=18), phi_deg=0, chi_deg=0
)
def __init__(self, bl: MXBeamline):
if bl == MXBeamline.X06DA:
self.__simulated = False
self.__base = "http://x06da-smargopolo.psi.ch:3000"
elif bl == MXBeamline.SIMULATED:
self.__simulated = True
self.__pos = self.SMARGON_HOME
else:
raise Exception("unknown beamline")
def gonget(self, thing: str) -> dict:
"""issue a GET for some API component on the smargopolo server"""
cmd = f"{self.__base}/{thing}"
r = requests.get(cmd)
if not r.ok:
raise Exception(
f"error getting {thing}; server returned {r.status_code} => {r.reason}"
)
return r.json()
def gonput(self, thing: str):
cmd = f"{self.__base}/{thing}"
r = requests.put(cmd)
if not r.ok:
raise Exception(
f"error putting {thing}; server returned {r.status_code} => {r.reason}"
)
def move_home(self, wait=False) -> None:
self.target = self.SMARGON_HOME
if wait:
self.wait()
@property
def mode(self) -> SmargonMode:
return SmargonMode.INITIALIZING
@mode.setter
def mode(self, mode: SmargonMode):
if self.__simulated:
return
self.gonput(f"mode?mode={mode}")
def initialize(self):
self.mode = SmargonMode.UNINITIALIZED
sleep(0.1)
self.mode = SmargonMode.INITIALIZING
def enable_correction(self):
if self.__simulated:
return
self.gonput("corr_type?corr_type=1")
def disable_correction(self):
if self.__simulated:
return
self.gonput("corr_type?corr_type=0")
@property
def readback(self) -> SmargonCoordinate:
if self.__simulated:
return self.__pos
scs = self.gonget("readbackSCS")
return SmargonCoordinate(
sh_mm=Coordinate(x=scs["SHX"], y=scs["SHY"], z=scs["SHZ"]),
phi_deg=scs["PHI"],
chi_deg=scs["CHI"],
)
@property
def target(self) -> SmargonCoordinate:
if self.__simulated:
return self.__pos
scs = self.gonget("targetSCS")
return SmargonCoordinate(
sh_mm=Coordinate(x=scs["SHX"], y=scs["SHY"], z=scs["SHZ"]),
phi_deg=scs["PHI"],
chi_deg=scs["CHI"],
)
@target.setter
def target(self, coord: SmargonCoordinate):
if self.__simulated:
self.__pos = coord
return
target_string = ""
if coord.sh_mm is not None:
target_string += "&SHX={:.5f}&SHY={:.5f}&SHZ={:.5f}".format(
coord.sh_mm.x, coord.sh_mm.y, coord.sh_mm.z
)
if coord.chi_deg is not None:
target_string += "&CHI={:.5f}".format(coord.chi_deg)
if coord.phi_deg is not None:
target_string += "&PHI={:.5f}".format(coord.phi_deg)
if target_string:
self.gonput(f"targetSCS?{target_string}")
def wait(self, timeout=60.0, tol=0.01, poll_time=0.01):
target = self.target
timeout = timeout + time()
while time() < timeout:
if target.eq(self.readback, tol):
break
if time() > timeout:
raise TimeoutError("Timed out waiting for Smargon to reach target")
sleep(poll_time)
-688
View File
@@ -1,688 +0,0 @@
import json
import logging
import random
import re
import time
from typing import List
from urllib.parse import urlparse
import requests
from aaredaqlib.logger_config import setup_logger
from aaredaqlib.models import (
PuckLoadedInfo,
SampleShortInfo,
DewarAddress,
SampleDewarAddress,
#PuckInfo,
)
from aareDBclient import (
PuckWithTellPosition,
)
from aaredaqlib.beamline import MXBeamline # noqa: F401
from mxlibs3.pshell_client import PShellClient
logger = setup_logger("aareDAQ")
class ManualMountException(Exception):
pass
class SmartMagnetFaultException(Exception):
pass
class TellMountFailedException(Exception):
pass
class TellCommandWhileBusyException(Exception):
pass
class TellConnectionException(Exception):
pass
VALID_DEWAR_POSITIONS = [f"{p}{n}" for n in "12345" for p in "ABCDEFX"]
POSITION_PARK = "pPark"
POSITION_COLD = "pCold"
POSITION_AUX = "pAux"
POSITION_DEWAR = "pDewar"
POSITION_HOME = "pHome"
POSITION_HEATER = "pHeatB"
#Nov 26 13:36:00 mx-x06da-queue-01.psi.ch AareDAQ[2944444]: 2025-11-26 13:36:00,388 - aareDAQ - ERROR - Error getting status: ('Connection aborted.', ConnectionResetError(104, 'Connection reset by peer'))
class TellClient:
def __init__(self, bl: MXBeamline):
self.__url = None
if bl == MXBeamline.SIMULATED:
self.__simulation = True
elif bl == MXBeamline.X06DA:
self.__simulation = False
self.__url = "http://x06da-tell.psi.ch:22222"
if self.__simulation:
print("TELL P-Shell in SIMULATION mode")
else:
print(f"Connecting TELL p-shell service at {self.__url} ...", end="")
hostname = urlparse(self.__url).hostname
try:
requests.get(f"{self.__url}/history/0", timeout=1.0)
except ConnectionError:
print(f"...connection to {hostname} failed")
raise
except requests.ReadTimeout:
print(f"...PShell service {hostname} is down")
raise
self.pshell = PShellClient(self.__url)
self._simulated_samples_info = {}
self._simulated_detected_pucks = []
self._simulated_mounted_sample = ""
self._simulated_current = 30.0
self._simulated_suppress = True
self._simulated_state = "Ready"
self._simulated_offset = 0.0
self._aborted = False
self.state = self.get_state()
self.debug = False
self._last_cmd_id = -1
@property
def url(self):
return self.__url
def smc_get_current(self) -> float:
if self.__simulation:
return 30.0
return json.loads(self.pshell.eval("smart_magnet.get_current()&"))
def smc_set_current(self, current: float):
if self.__simulation:
return
self.pshell.eval(f"smart_magnet.set_current({current:.1f})&")
def smc_get_suppress(self) -> bool:
if self.__simulation:
return self._simulated_suppress
return json.loads(self.pshell.eval("smart_magnet.get_supress()&"))
def smc_set_suppress(self, state: bool):
if self.__simulation:
self._simulated_suppress = state
return
self.pshell.eval(f"smart_magnet.set_supress({state})&")
def check_smc(self):
self.pshell.eval("smart_magnet.set_supress(False)&")
self.pshell.eval("smart_magnet.set_resting_current()&")
self.pshell.eval("smart_magnet.check_mounted(idle_time=1.0, timeout=1.0")
def magnet_blower(self, state):
"""turn on/off blower to the magnet for de-icying purposes
state = false (blower is off)
state = true (blower is on)
"""
if X06DA:
self.pshell.eval(f"set_pin_cleaner({state})&")
else:
cmd = "true" if state else "false"
self.pshell.eval(f'robot.evaluate("doFOut1={cmd}")&')
def get_state(self):
if self.__simulation:
return self._simulated_state
self.state = self.pshell.get_state()
return self.state
def get_result(self, command_id=-1):
if self.__simulation:
return {
"id": self._last_cmd_id,
"status": "completed",
"exception": "",
"return": (True, "PINCODE6546"),
}
return self.pshell.get_result(command_id)
def wait_ready(self):
if self.__simulation:
logger.info("simulated wait_ready")
time.sleep(3.0)
self._simulated_state = "Ready"
return
# Monitors event but polls every second just n case an event is missed
logger.debug(f"Waiting for robot to be ready. Current state: {self.state} Get state: {self.get_state()}")
while True:
if self.state != "Busy":
logger.debug(f"Robot is now ready...? {self.state} Get:{self.get_state()}")
break
time.sleep(0.2)
self.get_state()
if self.state != "Ready":
if self.state == "Initializing":
raise Exception("Tell reconnecting")
elif self.state == "Closing":
raise Exception("Tell is disconnecting")
raise Exception("Invalid state: " + str(self.state))
def set_in_mount_position(self, value):
if self.__simulation:
return
self.pshell.eval("in_mount_position = " + str(value) + "&")
def is_in_mount_position(self):
if self.__simulation:
return True
return self.pshell.eval("in_mount_position&").lower() == "true"
def set_simulated_mounted_sample(self, info):
self._simulated_mounted_sample = info
def set_simulated_samples_info(self, samples):
self._simulated_samples_info = samples
def set_simulated_detected_pucks(self, pucks):
self._simulated_detected_pucks = pucks
def get_samples_info(self) -> List[SampleShortInfo]:
if self.__simulation:
j = self._simulated_samples_info # FIXME
else:
j = json.loads(self.pshell.eval("get_samples_info()&"))
output: List[SampleShortInfo] = []
for i in j:
if len(i["puckAddress"]) == 2:
dewar_location = DewarAddress(
segment=i["puckAddress"][0], pos=i["puckAddress"][1]
)
else:
dewar_location = None
output.append(
SampleShortInfo(
puck_name=i["puckBarcode"],
dewar_name=i["dewarName"],
sample_name=i["sampleName"],
pin=i["samplePosition"],
user=i["userName"],
location=dewar_location,
)
)
return output
def set_samples_info(self, info: List[PuckWithTellPosition]):
if self.__simulation:
return
j = []
for x in info:
j.append(
{
"userName": x.pgroup,
"dewarName": x.dewar_name or "",
"puckName": x.puck_name,
"puckType": "Unipuck", # could use x.puck_type
"puckAddress": x.tell_position or "",
"puckBarcode": x.puck_name,
"sampleBarcode": "",
"sampleMountCount": 0,
"sampleName": "",
"samplePosition": 1,
"sampleStatus": "",
}
)
self.pshell.run("data/set_samples_info", pars=[json.dumps(j)], background=True)
# self.pshell.eval("set_samples_info(" + json.dumps(info) + ")&")
def start_cmd(self, cmd, *argv):
if self.__simulation:
return 666 # FIXME should be different?
cmd = cmd + "("
for a in argv:
cmd = cmd + (("'" + a + "'") if type(a) is str else str(a)) + ", "
cmd = cmd + ")"
ret = self.pshell.start_eval(cmd)
self.get_state()
return ret
def wait_cmd(self, cmd):
if self.__simulation:
return {True, "BARCODE_BIGUS"}
self.wait_ready()
result = self.get_result(cmd)
# print (result)
if result["exception"] is not None:
raise Exception(result["exception"])
return result["return"]
def is_cmd_completed(self, cmd):
if self.__simulation:
return True
return self.get_result(cmd)["status"] != "running"
def wait_mount_complete(self, timeout: float = 360):
if self.__simulation:
time.sleep(1.0)
return
logger.debug(f"Waiting for mount to complete. Is busy? {self.is_busy()}")
timeisup = timeout + time.time()
while time.time() < timeisup:
if not self.is_busy():
logger.debug(f"Finished waiting for mount to complete. Is busy? {self.is_busy()}")
break
time.sleep(0.2)
def check_command_ok(self, timeout: float = 360.0, msg: str = ""):
self.wait_mount_complete(timeout)
result = self.get_result(self._last_cmd_id)
if "completed" != result["status"]:
raise TellMountFailedException(f"{msg} {result}")
def estimate_mounting_time(self, segment) -> int:
try:
current_mounted = self.get_mounted_sample()
gripper_in_cold = self.is_in_cold()
if current_mounted is None:
unmount_needs_drying = 0 # might not have anything
unmount_needs_cooling = 0
else:
segment_in_cold = current_mounted.puck.segment in "ABCDEF"
unmount_needs_drying = int(gripper_in_cold and not segment_in_cold)
unmount_needs_cooling = int(not gripper_in_cold and segment_in_cold)
mount_needs_cooling = int(segment in "ABCDEF" and not gripper_in_cold)
mount_needs_drying = int(segment not in "ABCDEF" and gripper_in_cold)
needs_cooling = mount_needs_cooling + unmount_needs_cooling
needs_drying = mount_needs_drying + unmount_needs_drying
return needs_cooling * 30 + needs_drying * 120
except:
return 0
def mount(
self,
address: SampleDewarAddress,
force: bool = False, # kept for future
read_dm: bool = False, # read data matrix
auto_unmount: bool = False, # single command, if False it will raise exception
wait: bool = False, # blocking operation
timeout: float = 600.0,
):
SampleDewarAddress.model_validate(address)
segment = address.puck.segment
puck = address.puck.pos
sample = address.pin
if self.__simulation:
self._last_cmd_id = random.randint(1000, 9999)
print(
f"simulated mount({segment}, {puck}, {sample}) -> {self._last_cmd_id}"
)
self._simulated_mounted_sample = f"{segment}{puck}{sample}"
if random.random() < 0.1:
print("simulated failed mount")
raise TellMountFailedException(f"mount failed for {segment}{puck}")
return self._last_cmd_id
if self.is_busy():
raise TellCommandWhileBusyException("mount received while robot is busy")
logger.info(f"loading sample {sample} from segment {segment} - {puck}")
self._last_cmd_id = self.start_cmd(
"mount", segment, puck, sample, force, read_dm, auto_unmount
)
wait_timeout = timeout + self.estimate_mounting_time(segment)
logger.info("waiting for mount to complete")
if wait and segment in "ABCDEF":
event, value = self.pshell.wait_events({"state": None, "motion_task": "dry", "gripper_detection" : "No Pin in Gripper"}, timeout=wait_timeout)
if event is None or event == "state":
logger.info(f"event: {event} occurred with value: {value}, checking command completed okay")
self.check_command_ok(
timeout=wait_timeout, msg=f"Mount failed for {segment}{puck}-{sample}: "
)
return value
elif event == "gripper_detection":
logger.info(f"gripper detection: {event} occurred with value: {value}")
return value
elif event == "motion_task" and value == "dry":
logger.info(f"event: {event} occurred with value: {value}")
logger.info(" Drying occurring, releasing interface to user")
return value
else:
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 failed for {segment}{puck}-{sample}: "
)
elif wait and segment == "X":
logger.info("Loading an auxiliary puck")
self.check_command_ok(
timeout=wait_timeout, msg=f"Mount failed for {segment}{puck}-{sample}: "
)
logger.info("post waiting")
return None
def unmount(self, force=False, wait=False, timeout=360.0):
# Force has a meaning, will unmount even if smart magnet is not detecting sample
if self.__simulation:
print("simulated unmount")
return
if self.is_busy():
raise TellCommandWhileBusyException("mount received while robot is busy")
self._last_cmd_id = self.start_cmd("unmount", None, None, None, force)
if wait:
self.check_command_ok(timeout=timeout, msg="Unmount failed: ")
return self._last_cmd_id
def scan_pin(self, segment, puck, sample, force=False):
return self.start_cmd("scan_pin", segment, puck, sample, force)
def scan_puck(self, segment, puck, force=False):
return self.start_cmd("scan_puck", segment, puck, force)
def dry(self, heat_time=None, speed=None, wait_cold=None, wait=False):
if self.__simulation:
time.sleep(5.0)
self.pshell.wait_state("Ready", timeout=30.0)
self._last_cmd_id = self.start_cmd("dry", heat_time, speed, wait_cold)
if wait:
self.check_command_ok(timeout=360.0, msg=f"Dry failed")
def move_park(self, wait=False):
if self.__simulation:
return
self._last_cmd_id = self.start_cmd("move_park")
if wait:
self.check_command_ok(timeout=360.0, msg=f"Move to park failed")
def move_cold(self, reset_timestamp=False, wait=False):
if self.__simulation:
return
self._last_cmd_id = self.start_cmd("move_cold", reset_timestamp)
if wait:
self.check_command_ok(timeout=360.0, msg=f"Move to cold failed")
def trash(self):
if self.__simulation:
return
return self.start_cmd("trash_sample")
def abort_cmd(self):
if self.__simulation:
print("simulated abort")
return
self.pshell.abort()
self.pshell.eval("robot.stop_task()&")
def set_gonio_mount_position(self, homing=False):
if self.__simulation:
return
if homing:
self.pshell.eval("home_fast_table()")
self.pshell.eval("set_mount_position()")
def set_setting(self, key: str, value: str):
self.pshell.eval(f"set_setting('{key}', '{value}')&")
def get_setting(self, key: str) -> str:
return self.pshell.eval(f"get_setting('{key}')&")
def enable_room_temperature(self):
self.set_setting("room_temperature_enabled", "true")
def disable_room_temperature(self):
self.set_setting("room_temperature_enabled", "false")
def get_mounted_sample(self) -> SampleDewarAddress | None:
if self.__simulation:
ret = self._simulated_mounted_sample
else:
ret = self.pshell.eval("get_setting('mounted_sample_position')&").strip()
if not ret or len(ret) == 0:
return None
match = re.match(r"([A-Z])(\d)(\d{1,2})", ret)
if match:
segment, puck, sample = match.groups()
dewar_location = DewarAddress(segment=segment, pos=int(puck))
return SampleDewarAddress(puck=dewar_location, pin=int(sample))
else:
logger.warning(f"Failed to decode mounted sample position: {ret}")
return None
def get_system_check(self):
if self.__simulation:
if random.random() < 0.1:
raise RuntimeError("get_system_check_failed")
return "Ok"
return self.pshell.eval("system_check_msg()&")
def get_robot_state(self):
if self.__simulation:
return "Ready"
return self.pshell.eval("robot.state&")
def get_robot_status(self):
if self.__simulation:
return {
"powered": True,
"settled": True,
"speed": 100,
"empty": True,
"mode": "remote",
"task": None,
"pos": "pCold",
"open": True,
"status": "move",
}
status = self.pshell.eval("robot.take()&")
return eval(status) # FIXME ALL functions must return a valid JSON object
def get_speed(self) -> float:
if self.__simulation:
if random.random() < 0.1:
return random.choice([1, 5, 25, 50, 75, 90])
return 100.0
speed = self.get_robot_status()["speed"]
return float(speed)
def get_detected_pucks(self) -> List[PuckLoadedInfo]:
if self.__simulation:
j = self._simulated_detected_pucks
else:
j = json.loads(self.pshell.eval("get_pucks_info()&"))
output = []
for i in j:
if i["puckState"] == "Present":
puck_address = i["puckAddress"]
if len(puck_address) == 2:
output.append(
PuckLoadedInfo(
puck_name=i["puckBarcode"],
location=DewarAddress(
segment=puck_address[0], pos=int(puck_address[1])
),
),
)
return output
def set_pin_offset(self, value):
if self.__simulation:
print(f"simulated set_pin_offset {value}")
self._simulated_offset = value
return
self.pshell.eval("set_pin_offset(" + str(value) + ")&")
def get_pin_offset(self):
if self.__simulation:
print(f"simulated get_pin_offset -> {self._simulated_offset}")
return self._simulated_offset
try:
offset = float(self.pshell.eval("get_pin_offset()&"))
except Exception:
offset = 0.0
return offset
def get_current(self):
if self.__simulation:
return self._simulated_current
current = self.pshell.eval("smart_magnet.get_current_rb()&")
return float(current)
def set_current(self, current):
if self.__simulation:
self._simulated_current = current
return
self.pshell.eval("smart_magnet.set_current({:.1f})&".format(current))
current = self.pshell.eval("smart_magnet.get_current_rb()&")
return float(current)
def print_info(self):
print("State: " + str(self.get_state()))
print("Mounted sample: " + str(self.get_mounted_sample()))
print("System check: " + str(self.get_system_check()))
print("Robot state: " + str(self.get_robot_state()))
print("Robot status: ")
status = self.get_robot_status()
status = status
for k, v in status.items():
print(f"{k:>10s}: {v}")
print("Pin offset: " + str(self.get_pin_offset()))
print("Mount position: " + str(self.is_in_mount_position()))
print("")
def is_powered(self):
if self.__simulation:
return True
return self.get_robot_status()["powered"]
def check_enable_motion(self):
if self.__simulation:
if random.random() < 0.1:
raise RuntimeError("check_enable_motion failed")
if not self.is_powered():
self.pshell.eval("enable_motion()&")
def is_in_park(self):
if self.__simulation:
return True
return self.is_position(POSITION_PARK)
def is_in_home(self):
if self.__simulation:
return False
return self.is_position(POSITION_HOME)
def is_in_cold(self):
if self.__simulation:
return False
return self.is_position(POSITION_COLD)
def is_position(self, position: str) -> bool:
return position == self.get_robot_status()["pos"]
def get_task(self):
"""
robot_status = {
'powered': False,
'settled': True,
'speed': 10,
'empty': True,
'mode': 'remote',
'task': None,
'pos': 'pPark',
'open': True,
'status': 'hold'
}
:return:
"""
status = self.get_robot_status()
return status["task"]
def is_ready(self):
return "ready" == self.get_state().lower()
def is_busy(self):
return "busy" == self.get_state().lower()
def check_smart_magnet_mounted(self, timeout: float = 10.0, idle_time: float = 1.0, interval: float = 0.1):
initial_state = self.pshell.eval("smart_magnet.state&")
logger.debug(f"checking smart magnet_initial state: {initial_state}")
if initial_state == "Paused":
self.pshell.eval("smart_magnet.set_supress(False)&")
self.pshell.eval("smart_magnet.set_resting_current()&")
elif initial_state == "Fault":
logger.error(f"tell smart magnet is in unknown state {initial_state}")
raise SmartMagnetFaultException
#time.sleep(1.0)
state = self.pshell.eval("smart_magnet.state&")
try:
# sample_present = bool(self.pshell.eval(
# f"smart_magnet.check_mounted(idle_time={str(idle_time)}, timeout={str(timeout)}, interval={str(interval)})"))
#logger.debug(f"sample present: {sample_present} of type {type(sample_present)}")
#time.sleep(1.0)
if state == "Busy":
logger.debug('state busy')
self.pshell.eval("smart_magnet.set_supress(True)&")
self.pshell.eval("smart_magnet.state&")
sample_present = True
elif state == "Ready":
logger.debug('No sample detected, ready to mount')
sample_present = False
elif state == "Paused":
logger.debug("Smart magnet detection is paused")
return None
else:
self.pshell.eval("smart_magnet.set_supress(True)&")
logger.error(f"Tell smart magnet is in unknown state {state}")
raise SmartMagnetFaultException
if sample_present:
print(self.get_mounted_sample())
if self.get_mounted_sample() is None:
logger.warning("Check mount: A manually mounted sample is detected.")
logger.warning("Remove before mounting with the robot.")
raise ManualMountException
return True
elif self.get_mounted_sample():
logger.error("Check mount: No sample detected, but robot thinks is mounted")
raise SmartMagnetFaultException
return False
except Exception as e:
logger.error(f"check_smart_magnet_mounted failed: {e}")
raise e
#sample_present = False
def is_true(value):
"""check if argument is semantically true"""
value = str(value).lower()
return value != "0" or value in ("true", "yes", "on", "enabled")
def is_false(value):
return not is_true(value)
def is_valid_dewar_position(position):
return position in VALID_DEWAR_POSITIONS
-28
View File
@@ -1,28 +0,0 @@
[project]
name = "aaregui"
version = "0.2.72"
description = "Beamline control GUI"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"pyjwt==2.10.1",
"pyzmq==26.4.0",
"opencv-python-headless==4.11.0.86",
"PySide6==6.9.0",
"aaredaqlib==0.2.72"
]
[lint]
ignore = ["F401", "F541", "W503", "W504"]
[build-system]
requires = ["setuptools>=75.6.0"]
build-backend = "setuptools.build_meta"
[tool.uv.sources]
aaredb = { index = "psi"}
[[tool.uv.index]]
name = "psi"
url = "https://gitea.psi.ch/api/packages/mx/pypi/simple"
-40
View File
@@ -1,40 +0,0 @@
import os
import jwt
import requests
from aaredaqlib.models import TokenData
from aaredaqlib.logger_config import setup_logger
logger = setup_logger('aareGUI')
def auth(base_url: str | None) -> str:
curr_user = os.getlogin()
if base_url is None:
token_data = TokenData(sub=curr_user,
staff=True,
session=15,
pgroups=["p16371", "p22233"])
return jwt.encode(token_data.model_dump(), "ABC123")
try:
response = requests.post(
f"{base_url}/token",
data={
"username": curr_user,
"password": ""
},
headers={
"Content-Type": "application/x-www-form-urlencoded"
}
)
if response.status_code == 200:
response_json = response.json()
if "access_token" in response_json:
return response_json["access_token"]
except requests.RequestException as e:
logger.error(f"Authentication request failed: {e}")
return ""
@@ -1,38 +0,0 @@
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import QWidget, QGridLayout, QSlider
from aaredaqlib.models import DAQStatusModel
from aaregui.widgets.title_label import TitleLabel
class IlluminationPanel(QWidget):
light = Signal(int)
def __init__(self, parent=None):
super().__init__(parent)
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Front light", self), 0, 0, 1, 2)
self.is_sliding = False
self.slider = QSlider(orientation=Qt.Orientation.Horizontal, parent=self)
self.slider.setRange(0, 100)
self.slider.sliderPressed.connect(self.on_slider_pressed)
self.slider.sliderReleased.connect(self.on_slider_released)
grid_layout.addWidget(self.slider, 1, 0, 1, 2)
@Slot()
def on_slider_pressed(self):
self.is_sliding = True
@Slot()
def on_slider_released(self):
self.is_sliding = False
self.light.emit(self.slider.value())
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
if not self.is_sliding: # Update only if not sliding
self.slider.setValue(round(s.bl.light))
-98
View File
@@ -1,98 +0,0 @@
import json
import cv2
import numpy as np
import zmq
from PySide6.QtCore import QThread, Signal
from PySide6.QtGui import QImage, QPixmap
class SampleCameraThread(QThread):
# Define a signal to communicate messages from the thread to the main GUI
camera_image = Signal(QPixmap)
def __init__(self, zmq_url: str, parent=None):
super().__init__(parent)
context = zmq.Context()
self.__socket = context.socket(zmq.SUB)
self.__socket.setsockopt(zmq.SUBSCRIBE, b"")
self.__socket.setsockopt(zmq.RCVTIMEO, 500)
self.__socket.connect(zmq_url)
self.running = True
def run(self):
while self.running:
try:
r = self.__socket.recv_multipart()
if len(r) != 2:
continue
meta, data = r
header = json.loads(meta)
header_shape = header["shape"]
if header["type"] == "uint8" and len(header_shape) == 2:
bayer_image = np.frombuffer(data, dtype=np.uint8)
bayer_image = bayer_image.reshape(header_shape)
rgb_image = cv2.cvtColor(bayer_image, cv2.COLOR_BAYER_RG2RGB)
#cv2.COLOR_BAYER_GB2RGB for ethernet connection
rgb_image = rgb_image[:, ::-1, :].copy()
qimage = QImage(rgb_image.data, header_shape[1], header_shape[0], #For ethernet need header_shape[0], header_shape[1], header_shape[0]*3,
QImage.Format.Format_RGB888)
self.camera_image.emit(QPixmap.fromImage(qimage))
else:
print("Sample camera image has wrong dimensions")
except zmq.Again: # Timeout occurred
continue # Check self.running again
except Exception as e:
print(f"Error in sample camera thread {e}")
def stop(self):
# Signal the thread to stop
self.running = False
if self.__socket:
self.__socket.close()
self.quit()
self.wait()
class PredictionSubscriber(QThread):
# emits parsed JSON payload (dict with keys: time, frame_id, shape, boxes)
prediction = Signal(dict)
def __init__(self, pred_zmq_url: str, topic: bytes = b"", parent=None):
super().__init__(parent)
self._ctx = zmq.Context()
self._sock = self._ctx.socket(zmq.SUB)
if topic:
self._sock.setsockopt(zmq.SUBSCRIBE, topic)
else:
self._sock.setsockopt(zmq.SUBSCRIBE, b"")
self._sock.connect(pred_zmq_url)
self.running = True
def run(self):
while self.running:
try:
parts = self._sock.recv_multipart()
if not parts:
continue
# publisher sends either raw JSON or [topic, json]
payload_bytes = parts[-1]
try:
payload = json.loads(payload_bytes.decode("utf-8"))
except Exception:
continue
self.prediction.emit(payload)
except Exception as e:
print("PredictionSubscriber error:", e)
break
def stop(self):
self.running = False
try:
self._sock.close()
except Exception:
pass
self.quit()
print("Prediction thread stopped. Exiting...")
self.wait()
+10 -6
View File
@@ -1,24 +1,28 @@
[project]
name = "aaredaq"
version = "0.2.72"
description = "AareDAQ data acquisition server"
version = "0.3.0"
description = "AareDAQ (with GUI)"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"pydantic==2.11.4",
"numpy==2.2.5",
"jfjoch_client==1.0.0rc126",
"pyJWT==2.10.1",
"pyzmq==26.4.0",
"opencv-python-headless==4.11.0.86",
"PySide6==6.9.0",
"requests==2.32.4",
"pyepics==3.5.8",
"redis==6.2.0",
"python-redis-lock==4.0.0",
"PyJWT==2.10.1",
"fastapi==0.115.13",
"uvicorn==0.34.2",
"ultralytics==8.3.133",
"aaredb==0.1.1a42",
"opencv-python-headless==4.11.0.86",
"python_multipart==0.0.20",
"websocket-client==1.8.0",
"sseclient-py==1.8.0",
"aaredaqlib==0.2.72"
"psi-pshell==2.1.0",
]
[lint]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+59
View File
@@ -0,0 +1,59 @@
import cv2
import numpy as np
def focus_measure_edges(gray: np.ndarray, mask: np.ndarray | None = None, verbose: bool = False) -> float:
# mild denoise (optional but usually stabilizes the curve)
gray = cv2.GaussianBlur(gray, (3, 3), 0)
gx = cv2.Scharr(gray, cv2.CV_64F, 1, 0)
gy = cv2.Scharr(gray, cv2.CV_64F, 0, 1)
g2 = gx * gx + gy * gy
roi = g2[mask] if mask is not None else g2.reshape(-1)
if roi.size == 0:
return 0.0
# threshold relative to median -> knocks out noise floor
t = float(np.median(roi) * 3.0)
strong = roi[roi > t]
if strong.size == 0:
return 0.0
if verbose:
print(f"mask pixels: {mask.sum()}, "
f"focus={strong.mean():.2f}"
f"strong_size={strong.size}")
return float(strong.mean()) # higher = sharper
def focus_measure_blob_size(gray: np.ndarray, mask: np.ndarray | None = None) -> float:
"""
Measures sharpness for a single bright blob.
Higher = sharper (smaller blob).
"""
g = gray.astype(np.float64)
if mask is not None:
g = np.where(mask, g, 0.0)
# Background subtraction is crucial for blob metrics
# Use a large-ish blur as background estimate (tune ksize to your scale)
bg = cv2.GaussianBlur(g, (0, 0), sigmaX=10.0, sigmaY=10.0)
s = g - bg
s[s < 0] = 0.0
total = float(s.sum())
if total <= 0:
return 0.0
h, w = s.shape
y, x = np.mgrid[0:h, 0:w]
cx = float((s * x).sum() / total)
cy = float((s * y).sum() / total)
# intensity-weighted second central moment (variance)
var = float((s * ((x - cx) ** 2 + (y - cy) ** 2)).sum() / total)
# smaller var => sharper, so invert
return float(1.0 / (var + 1e-9))
@@ -10,8 +10,6 @@ class MXBeamline(Enum):
def mx_beamline() -> MXBeamline:
name = os.getenv("BEAMLINE_XNAME")
if name is None:
name = os.getenv("BEAMLINE", "SIMULATED")
name = os.getenv("BEAMLINE")
name=name.strip().upper()
return MXBeamline[name] if name in MXBeamline.__members__ else MXBeamline.SIMULATED
@@ -102,3 +102,21 @@ def positive_coords(value: Coordinate) -> Coordinate:
if value.x <= 0 or value.y <= 0:
raise ValueError("Coordinates must be positive")
return value
class AerotechCoordinate(BaseModel):
at_mm: Optional[Coordinate] = None
omega_deg: Optional[float] = None
def eq(self, other: "AerotechCoordinate", tol: float) -> bool:
return (
abs(self.at_mm.x - other.at_mm.x) < tol
and abs(self.at_mm.y - other.at_mm.y) < tol
and abs(self.at_mm.z - other.at_mm.z) < tol
and abs(self.omega_deg - other.omega_deg) < tol
)
def __eq__(self, other: object) -> bool:
if isinstance(other, AerotechCoordinate):
return self.eq(other, tol=0.01)
return NotImplemented
@@ -36,7 +36,7 @@ class DiffractionGeometry(BaseModel):
def resolution_angstrom(self, exp_dtz_mm: float) -> float:
if exp_dtz_mm <= 0:
raise ValueError("Detector distance must be positive")
raise ValueError(f"Detector distance must be positive {exp_dtz_mm}")
theta = math.atan(self.detector_radius_mm / exp_dtz_mm)*0.5
return self.wavelength_angstrom / (2 * math.sin(theta))
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from enum import StrEnum
class AuthErrorCode(StrEnum):
"""
Stable, machine-readable error codes used across the API.
Rules:
- never rename an existing value (treat as public API)
- only add new values
"""
# Generic / defaults
AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR"
AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED"
FORBIDDEN = "FORBIDDEN"
HTTP_ERROR = "HTTP_ERROR"
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR"
# Auth/JWT
INVALID_TOKEN = "INVALID_TOKEN"
SESSION_ALREADY_ACTIVE = "SESSION_ALREADY_ACTIVE"
# Authorization
NOT_STAFF = "NOT_STAFF"
NOT_IN_ACTIVE_PGROUP = "NOT_IN_ACTIVE_PGROUP"
class DAQErrorCode(StrEnum):
BEAMLINE_BUSY = "BEAMLINE_BUSY"
_ERROR_CODE_HELP: dict[str, str] = {
# Auth/JWT
AuthErrorCode.AUTHENTICATION_ERROR: (
"Generic authentication problem. Usually means the request lacked valid credentials "
"(expired/invalid token, missing Authorization header, etc.)."
),
AuthErrorCode.AUTHENTICATION_FAILED: (
"Authentication failed during login/token creation. Typically incorrect credentials "
"or an inability to validate the user."
),
AuthErrorCode.INVALID_TOKEN: (
"The provided token could not be decoded/validated (bad signature, expired, malformed). "
"Re-authenticate to obtain a new token."
),
AuthErrorCode.SESSION_ALREADY_ACTIVE: (
"A different session currently owns control. Use “force current session” (if allowed) "
"or wait for the active session to expire/end."
),
# Authorization
AuthErrorCode.FORBIDDEN: (
"Generic permissions failure. The user is authenticated but not allowed to perform this action."
),
AuthErrorCode.NOT_STAFF: (
"This action requires staff privileges. Log in with a staff account or ask staff to perform it."
),
AuthErrorCode.NOT_IN_ACTIVE_PGROUP: (
"You are not a member of the currently active p-group. Change p-group or use an account "
"that belongs to the active group."
),
# Generic
AuthErrorCode.HTTP_ERROR: (
"Generic HTTP error wrapper. The server returned an HTTPException that wasnt mapped to a more specific code."
),
AuthErrorCode.INTERNAL_SERVER_ERROR: (
"Unhandled server error. Check server logs for a stack trace and context."
),
DAQErrorCode.BEAMLINE_BUSY: (
"Beamline state is set to Busy by prior action. If this state persists an additional error may have occurred, "
"preventing the state from being released, this should timeout within 10 minutes."
"If this occurs please seek assistance from your local contact."
)
}
def error_code_help(code: str) -> str | None:
"""
Return a human help message for a code string, if known.
Accepts either enum value strings or raw strings.
"""
if not code:
return None
return _ERROR_CODE_HELP.get(str(code))
def export_error_code_help() -> dict[str, str]:
"""
Export help text as {"CODE": "help text", ...}
"""
return {str(k): str(v) for k, v in _ERROR_CODE_HELP.items()}
def export_error_codes_grouped() -> dict[str, dict[str, str]]:
"""
Export codes grouped by enum class name:
{
"AuthErrorCode": {"INVALID_TOKEN": "INVALID_TOKEN", ...},
"DAQErrorCode": {"BEAMLINE_BUSY": "BEAMLINE_BUSY", ...}
}
"""
enums: tuple[type[StrEnum], ...] = (AuthErrorCode, DAQErrorCode)
return {e.__name__: {c.name: str(c.value) for c in e} for e in enums}
def export_error_codes() -> dict[str, str]:
"""
Backwards-compatible, flat export used by older clients/tests/docs:
{"INVALID_TOKEN": "INVALID_TOKEN", ...}
NOTE: This intentionally exports only AuthErrorCode to avoid breaking
existing consumers that assume a flat map and/or specific keys.
"""
return {c.name: str(c.value) for c in AuthErrorCode}
+190
View File
@@ -0,0 +1,190 @@
from __future__ import annotations
from aare.common.logger_config import setup_logger
from aare.common.error_codes import AuthErrorCode, DAQErrorCode
logger = setup_logger("aareDAQ")
class TransformationInvalidException(Exception):
def __init__(self, message: str = "Transformation is not implemented"):
super().__init__(message)
self.message = message
logger.error(message, extra={"exception:": Exception})
def __str__(self) -> str:
return self.message
class LoopCenteringFailed(Exception):
def __init__(self, message: str = "Loop Centering did not detect a sample"):
super().__init__(message)
self.message = message
logger.error(message, extra={"exception:": Exception})
def __str__(self) -> str:
return self.message
class MountingFailed(Exception):
def __init__(self, message: str = "A sample was not mounted"):
super().__init__(message)
self.message = message
logger.error(message, extra={"exception:": Exception})
def __str__(self) -> str:
return self.message
class WarningTellException(Exception):
def __init__(self, message: str = "Warning error in TELL"):
super().__init__(message)
self.message = message
logger.error(message, extra={"exception:": Exception})
def __str__(self) -> str:
return self.message
class CriticalTellException(Exception):
def __init__(self, message: str = "Critical error in TELL"):
super().__init__(message)
self.message = message
logger.error(message, extra={"exception:": Exception})
def __str__(self) -> str:
return self.message
class AXCFailed(Exception):
def __init__(self, message: str = "Auto X-ray centering failed"):
super().__init__(message)
self.message = message
logger.error(message, extra={"exception:": Exception})
def __str__(self) -> str:
return self.message
class BeamlineBusyException(Exception):
def __init__(self, message: str = "Beamline is in busy state"):
super().__init__(message)
self.message = message
logger.error(message, extra={"exception:": Exception})
def __str__(self) -> str:
return self.message
class SampleException(Exception):
def __init__(self, message: str = "Sample not found"):
super().__init__(message)
self.message = message
logger.error(message, extra={"exception:": Exception})
def __str__(self) -> str:
return self.message
class AuthenticationException(Exception):
def __init__(self,
message: str = "Authentication failed.",
*,
status_code: int = 401,
headers: dict[str, str] | None = None,
code: AuthErrorCode = AuthErrorCode.AUTHENTICATION_FAILED):
super().__init__(message)
self.message = message
self.status_code = status_code
self.headers = headers
self.code = code
logger.error(message, extra={"exception:": Exception})
def __str__(self) -> str:
return self.message
class UserRightsException(Exception):
def __init__(self,
message: str = "User does not have rights to perform this action.",
*,
status_code: int = 403,
headers: dict[str, str] | None = None,
code: AuthErrorCode = AuthErrorCode.FORBIDDEN):
super().__init__(message)
self.message = message
self.status_code = status_code
self.headers = headers
self.code = code
logger.error(message, extra={"exception:": Exception})
def __str__(self) -> str:
return self.message
class SmargonCommunicationError(Exception):
"""
Raised when Smargon HTTP communication fails (connection refused, timeout, bad HTTP status, etc).
Keep the original exception in `__cause__` by using `raise ... from e`.
"""
def __init__(
self,
message: str = "Smargon communication error",
*,
endpoint: str | None = None,
base_url: str | None = None,
operation: str | None = None, # e.g. "GET" / "PUT"
status_code: int | None = None,
):
super().__init__(message)
self.message = message
self.endpoint = endpoint
self.base_url = base_url
self.operation = operation
self.status_code = status_code
logger.error(
message,
extra={
"device": "smargon",
"operation": operation,
"endpoint": endpoint,
"base_url": base_url,
"status_code": status_code,
},
)
def __str__(self) -> str:
return self.message
class TellCommunicationError(Exception):
"""
Raised when TELL HTTP/PShell communication fails (timeouts, connection refused, etc).
Intended to be caught centrally by FastAPI exception handlers.
"""
def __init__(
self,
message: str = "TELL communication error",
*,
endpoint: str | None = None,
base_url: str | None = None,
operation: str | None = None, # e.g. "GET"
):
super().__init__(message)
self.message = message
self.endpoint = endpoint
self.base_url = base_url
self.operation = operation
logger.error(
message,
extra={
"device": "tell",
"operation": operation,
"endpoint": endpoint,
"base_url": base_url,
},
)
def __str__(self) -> str:
return self.message
@@ -1,12 +1,11 @@
import json
import time
from typing import Tuple, Dict, List, Iterable, Optional
from typing import Tuple, Dict, List, Optional
import numpy as np
from scipy.optimize import curve_fit
import statistics
import math
from aaredaqlib.logger_config import setup_logger
from aaredaqlib.models import MLBoxModel, MLOutputModel
from aare.common.logger_config import setup_logger
logger = setup_logger("aareDAQ")
@@ -3,10 +3,9 @@ from typing import List, Optional, Callable
import numpy as np
from scipy import ndimage
from aaredaqlib.coordinate import Coordinate
from aaredaqlib.models import CrystalSize
from aaredaqlib.raster_grid import RasterGridRequest, CenterOfMassModel
from aaredaqlib.logger_config import setup_logger
from aare.common.models import CrystalSize
from aare.common.raster_grid import RasterGridRequest, CenterOfMassModel
from aare.common.logger_config import setup_logger
logger = setup_logger('aareDAQ')
@@ -63,7 +63,7 @@ def setup_logger(name="aareDAQ", base_dir: str | None = '~/tmp/mxlogs', config_p
h["filename"] = abs_filename
log_dir = os.path.dirname(abs_filename)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir, exist_ok=True)
os.makedirs(log_dir, exist_ok=True)
logging.config.dictConfig(config)
logging.getLogger("redis_lock").setLevel(logging.WARNING)
@@ -3,16 +3,14 @@ import re
from enum import Enum
from typing import Annotated, Literal, Tuple, List, Optional
import numpy as np
from pydantic import BaseModel, Field, field_validator, AfterValidator
from aaredaqlib.coordinate import Coordinate, positive_coords
from aaredaqlib.diffraction_geometry import DiffractionGeometry
from aaredaqlib.sample_geometry import SampleGeometryModel
from aare.common.coordinate import Coordinate, positive_coords
from aare.common.diffraction_geometry import DiffractionGeometry
from aare.common.sample_geometry import SampleGeometryModel
from jfjoch_client.models.scan_result import ScanResult
from aaredaqlib.beamline import MXBeamline
from aare.common.beamline import MXBeamline
class StagePositionEnum(Enum):
MEASURE = 0
@@ -651,7 +649,8 @@ def def_loop_centering_zoom(beamline) -> ZoomModel:
if beamline == MXBeamline.X06DA:
return ZoomModel(z={1: SampleCameraSettings(gain=0, exposure=0.05)})
elif beamline == MXBeamline.X10SA:
return ZoomModel(z={1: SampleCameraSettings(gain=0, exposure=0.05)})
return ZoomModel(z={1: SampleCameraSettings(gain=0, exposure=0.002),
280: SampleCameraSettings(gain=0, exposure=0.002)})
elif beamline == MXBeamline.X06SA:
return ZoomModel(z={1: SampleCameraSettings(gain=0, exposure=0.05)})
elif beamline == MXBeamline.SIMULATED:
@@ -675,8 +674,8 @@ def zoom_manager(mode: ZoomModeEnum = ZoomModeEnum.User, beamline: MXBeamline =
class AutofocusSettings(BaseModel):
center_x_pxl: float
center_y_pxl: float
center_x_pxl: float | None # Use beam center
center_y_pxl: float | None # Use beam center
radius_pxl: float
z_range_um: float
z_steps: int
@@ -684,7 +683,8 @@ class AutofocusSettings(BaseModel):
class BeamlineStatus(BaseModel):
name: str
ring_current_mA: float
light: Annotated[float, Field(ge=0.0, le=100.0)]
front_light: Annotated[float, Field(ge=0.0, le=100.0)]
back_light: Annotated[float, Field(ge=0.0, le=100.0)]
cryojet_K: float
shutter_open: bool
exp_shutter_open: bool | None
@@ -719,6 +719,12 @@ class DAQStatusModel(BaseModel):
last_best_b_factor: float | None = None
crystal_size: CrystalSize = CrystalSize(x=0,y=0,z=0)
tell_connected: bool = True
tell_error: str | None = None
smargon_connected: bool = True
smargon_error: str | None = None
class BeamlineSettingsModel(BaseModel):
dtz_max: float | None = 1600.0
dtz_min: float | None = 120.0
@@ -731,7 +737,7 @@ class BeamlineSettingsModel(BaseModel):
camera_max_magnification: float | None = 1.0
camera_min_magnification: float | None = 500.0
camera_translation_factor_a: float | None = 0.00253
camera_translation_factor_b: float | None = 255.0
camera_translation_factor_b: float | None = 512.0
class CryojetSettingsModel(BaseModel):
cryojet_park_position: float | None = 12.0
@@ -777,4 +783,7 @@ class ScanResultPayloadModel(BaseModel):
sample_id: int
attach_image: bool = True
beam_mark_pxl: tuple[float, float]
beam_size_mm: Annotated[Coordinate, AfterValidator(positive_coords)]
beam_size_mm: Annotated[Coordinate, AfterValidator(positive_coords)]
class RecoveryActionRequest(BaseModel):
confirmation_code: str
@@ -5,8 +5,8 @@ import numpy as np
from jfjoch_client.models.scan_result import ScanResult
from pydantic import Field, AfterValidator, BaseModel
from aaredaqlib.coordinate import Coordinate, positive_coords, SmargonCoordinate
from aaredaqlib.sample_geometry import SampleGeometryModel
from aare.common.coordinate import Coordinate, positive_coords, SmargonCoordinate
from aare.common.sample_geometry import SampleGeometryModel
class RasterGridRequest(BaseModel):
@@ -1,7 +1,7 @@
from jfjoch_client.models.scan_result import ScanResult
from pydantic import BaseModel
from aaredaqlib.coordinate import SmargonCoordinate
from aare.common.coordinate import SmargonCoordinate
class RotationScanRequest(BaseModel):
@@ -3,7 +3,7 @@ from typing import Annotated
import numpy as np
from pydantic import BaseModel, Field, AfterValidator
from aaredaqlib.coordinate import Coordinate, SmargonCoordinate, positive_coords
from aare.common.coordinate import Coordinate, SmargonCoordinate, positive_coords
class SampleGeometryModel(BaseModel):
@@ -4,11 +4,11 @@ import json
import os
from typing import List, Optional
import aareDBclient
import aareDB
import cv2
import numpy as np
import requests
from aareDBclient import (
from aareDB import (
SetTellPosition,
SampleEventCreate,
SampleEventType,
@@ -21,21 +21,18 @@ from aareDBclient import (
BeamlineParametersInput,
ExperimentParametersCreate)
from aaredaqlib.coordinate import Coordinate
from aaredaqlib.diffraction_geometry import DiffractionGeometry
from aaredaqlib.logger_config import setup_logger
from aaredaqlib.models import (
from aare.common.coordinate import Coordinate
from aare.common.logger_config import setup_logger
from aare.common.models import (
SampleShortInfo,
PuckLoadedInfo,
DewarAddress,
SampleShortInfoList,
DAQStatusModel, SessionStatus, ScanResultPayloadModel,
DAQStatusModel, ScanResultPayloadModel,
)
from aaredaqlib.beamline import MXBeamline
from aaredaqlib.raster_grid import RasterGridRequest, RasterPayloadModel, CenterOfMassModel
from aaredaqlib.rotation_scan import RotationScanRequest
from aaredaqlib.sample_geometry import SampleGeometryModel
from aare.common.beamline import MXBeamline
from aare.common.raster_grid import RasterGridRequest, RasterPayloadModel, CenterOfMassModel
from aare.common.rotation_scan import RotationScanRequest
from aare.common.sample_geometry import SampleGeometryModel
from jfjoch_client.models import ScanResult
@@ -47,16 +44,16 @@ class AareWrapper:
bl: MXBeamline,
host: str = "https://mx-db-01.psi.ch/dispatcher",
):
configuration = aareDBclient.Configuration(host=host)
configuration = aareDB.Configuration(host=host)
configuration.verify_ssl = False # Disable SSL verification
self.client = aareDBclient.ApiClient(configuration)
self.client = aareDB.ApiClient(configuration)
self.client.default_headers["X-Shared-Password"] = os.getenv("AAREDB_SHARED_PASSWORD")
self.__host = host
self.__tell_api = aareDBclient.TellsRunnerApi(self.client)
self.__sample_api = aareDBclient.SamplesRunnerApi(self.client)
self.__proc_api = aareDBclient.ProcessingsRunnerApi(self.client)
self.__raster_api = aareDBclient.GridscanRunnerApi(self.client)
self.__tell_api = aareDB.TellsRunnerApi(self.client)
self.__sample_api = aareDB.SamplesRunnerApi(self.client)
self.__proc_api = aareDB.ProcessingsRunnerApi(self.client)
self.__raster_api = aareDB.GridscanRunnerApi(self.client)
self.__bl = bl
def set_pucks_beamline(self, input_list: List[PuckLoadedInfo]):
@@ -76,7 +73,7 @@ class AareWrapper:
print(ret)
def create_manual_sample(self, s: SampleShortInfo):
from aareDBclient.models import ManualSampleCreate
from aareDB.models import ManualSampleCreate
manual_sample = ManualSampleCreate(
pgroup=s.user,
@@ -188,7 +185,7 @@ class AareWrapper:
except Exception as e:
logger.error(f"Error sending message to db: {e}")
def upload_image(self, sample_id: int, filename: str, bgr_image: np.ndarray):
def upload_image(self, sample_id: int, filename: str, bgr_image: np.ndarray, message: str | None = None):
_, buffer = cv2.imencode('.jpg', bgr_image)
jpeg_bytes = io.BytesIO(buffer)
url = f"{self.__host}/protected_router/sample_runner/{sample_id}/upload-images"
@@ -196,9 +193,16 @@ class AareWrapper:
"accept": "application/json",
"X-Shared-Password": os.getenv("AAREDB_SHARED_PASSWORD")
}
response = requests.post(url,
files={'uploaded_file': (filename + ".jpg", jpeg_bytes, "image/jpeg")},
verify=False, headers=headers)
request_kwargs = {
"files": {'uploaded_file': (filename + ".jpg", jpeg_bytes, "image/jpeg")},
"verify": False,
"headers": headers,
}
if message is not None:
request_kwargs["data"] = {"comment": message}
response = requests.post(url, **request_kwargs)
logger.debug(f"Response status code: {response.status_code}")
def upload_jpg(self, sample_id: int, filename: str, jpg_image):
@@ -9,7 +9,9 @@ from fastapi import HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from aaredaq.config import BeamlineConfig
from aare.daq.config import BeamlineConfig
from aare.common.exception_handler import AuthenticationException, UserRightsException, AuthErrorCode
if os.environ.get("JWT_AAREDAQ_KEY") is None:
raise Exception("JWT_AAREDAQ_KEY environment variable not set, cannot guarantee safe authentication.")
@@ -58,20 +60,21 @@ def parse_token(token: str) -> TokenData:
token = TokenData(**payload)
return token
except jwt.PyJWTError as e:
print(f"JWT error {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
raise AuthenticationException(
message="Invalid token",
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
code=AuthErrorCode.INVALID_TOKEN,
) from e
def check_jwt_ro(cfg: BeamlineConfig, data: TokenData) -> None:
active_pgroup = cfg.pgroup
if not data.staff and (active_pgroup is None or active_pgroup not in data.pgroups):
print("Not member of a currently active p-group.")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not member of a currently active p-group.",
raise UserRightsException(
message="Not member of a currently active p-group.",
status_code=403,
code=AuthErrorCode.NOT_IN_ACTIVE_PGROUP,
)
@@ -81,30 +84,32 @@ def check_jwt_rw(cfg: BeamlineConfig, data: TokenData) -> None:
try:
cfg.try_set_active_session(data.session, SESSION_EXPIRE_SECONDS)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Another session is active.",
raise AuthenticationException(
message="Another session is active.",
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
)
code=AuthErrorCode.SESSION_ALREADY_ACTIVE,
) from e
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,
)
def check_jwt_staff(cfg: BeamlineConfig, data: TokenData) -> None:
if not data.staff:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not member of the MX staff.",
headers={"WWW-Authenticate": "Bearer"},
)
check_jwt_staff_only(data)
try:
cfg.try_set_active_session(data.session, SESSION_EXPIRE_SECONDS)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Another session is active.",
raise AuthenticationException(
message="Another session is active.",
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
)
code=AuthErrorCode.SESSION_ALREADY_ACTIVE,
) from e
def force_current_sesion(cfg: BeamlineConfig, data: TokenData) -> None:
cfg.force_set_active_session(data.session, SESSION_EXPIRE_SECONDS)
@@ -6,8 +6,8 @@ from typing import Tuple, List
import numpy as np
import redis
import redis_lock
from aaredaqlib.coordinate import Coordinate
from aaredaqlib.models import (
from aare.common.coordinate import Coordinate
from aare.common.models import (
BeamlineSettingsModel,
BeamMarkCoeffModel,
ZoomModeEnum,
@@ -18,11 +18,13 @@ from aaredaqlib.models import (
FluorescenceSpectrumOutputModel, CrystalSize, SimpleStrategyInputModel, SimpleScanParameters
)
from aaredaqlib.beamline import MXBeamline
from aaredaqlib.logger_config import setup_logger
from aare.common.beamline import MXBeamline
from aare.common.logger_config import setup_logger
from aare.common.exception_handler import BeamlineBusyException
ABR_POS_ALIGN_DEF = Coordinate(x=-18, y=-0.266, z=0)
ABR_POS_MOUNT = Coordinate(x=-18, y=0, z=0)
ABR_POS_MOUNT = Coordinate(x=0, y=0, z=0)#Coordinate(x=-18, y=0, z=0)
ABR_OMEGA_MOUNT = 0.0
logger = setup_logger("aareDAQ")
@@ -47,10 +49,6 @@ def base64_to_numpy(encoded_str: str | None) -> np.ndarray | None:
return np.load(buffer) # Load buffer as a NumPy array
class BeamlineBusyException(Exception):
pass
class BeamlineConfig:
"""
Manages the configuration and state of a beamline system by interacting with a Redis
@@ -84,10 +82,23 @@ class BeamlineConfig:
@property
def active_session(self) -> int | None:
tmp = self.__client.get(f"{self.__bl}:active_session")
if tmp is None:
"""
Read active_session from Redis and convert to int.
Returns:
int if present and valid, otherwise None.
"""
raw = self.__client.get(f"{self.__bl}:active_session")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
logger.warning(
"Invalid active_session value in redis; treating as missing",
extra={"beamline": self.__bl, "raw": raw},
)
return None
return int(tmp)
def session_status(self, session: int) -> SessionStatus:
return SessionStatus(session=self.session_state(session),
@@ -104,12 +115,12 @@ class BeamlineConfig:
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
self.__client, f"{self.__bl}:active_session_lock", expire=10
):
tmp = self.__client.get(f"{self.__bl}:active_session")
if tmp is None:
active = self.active_session
if active is None:
self.__client.set(f"{self.__bl}:active_session", session)
elif int(tmp) != session:
elif active != session:
raise Exception(
"There is already active session with different id. Try again later."
)
@@ -118,26 +129,28 @@ 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
self.__client, f"{self.__bl}:active_session_lock", expire=10
):
tmp = self.__client.get(f"{self.__bl}:active_session")
if tmp == session:
self.__client.expire(f"{self.__bl}:active_session", expiry_sec, gt=True)
elif int(tmp) != session:
raise Exception(
"There is already active session with different id. Try again later."
)
else:
active = self.active_session
if active is None:
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)
else:
raise Exception(
"There is already active session with different id. Try again later."
)
def end_active_session(self, session: int) -> None:
with redis_lock.Lock(
self.__client, f"{self.__bl}:active_session_lock", expire=10
):
if int(self.__client.get(f"{self.__bl}:active_session")) == session:
active = self.active_session
if active is None:
return
if active == session:
self.__client.delete(f"{self.__bl}:active_session")
def force_set_active_session(self, session: int, expiry_sec: int) -> None:
@@ -554,12 +567,6 @@ class BeamlineConfig:
else:
self.__client.set(f"{self.__bl}:last_best_b_factor", last_best_b_factor)
@crystal_size.setter
def crystal_size(self, xtal_size: CrystalSize):
self.__client.set(f"{self.__bl}:crystal_size", xtal_size.model_dump_json())
@property
def simple_input_parameters(self) -> SimpleStrategyInputModel | None:
tmp = self.__client.get(f"{self.__bl}:simple_input_params")
@@ -600,7 +607,14 @@ class BeamlineConfig:
tmp = self.__client.get(f"{self.__bl}:failed_mount_count")
if tmp is None:
return 0
return tmp
try:
return int(tmp)
except (TypeError, ValueError):
logger.warning(
"Failed Mount Count is not an integer, resetting to 0.",
extra={"beamline": self.__bl, "tmp": tmp},
)
return 0
@failed_mount_count.setter
def failed_mount_count(self, count:int):
File diff suppressed because it is too large Load Diff
+355
View File
@@ -0,0 +1,355 @@
# Abstractions of devices for beamline
# Each "standard" device needs three elements:
# - property to read device value
# - setter with option to do sync/async move
# - property setter, which assumes that sync move is done (excl. zoom, which is async by default)
import time
from enum import Enum
import numpy as np
from epics import PV
from fontTools.feaLib.ast import deviceToString
from aare.common.beamline import MXBeamline
from aare.common.coordinate import Coordinate, SmargonCoordinate
from aare.common.models import SampleCameraSettings, StagePositionEnum
from aare.devices import smargon, aerotech
from aare.devices.area_detector import epicsAD, AutoEnum
from aare.devices.enum_pv import EnumPV
from aare.devices.my_motor import MyMotor
from aare.devices.set_get_pv import SetGetPV, PredefinedPV
from aare.devices.tell_client import make_tell_client
class BeamlineDevices:
def __init__(self, beamline: MXBeamline):
BEAMLINE = beamline.value.upper()
self.tell = make_tell_client(beamline)
self.__aerotech = aerotech.AerotechControllerEpics(beamline)
self.aerotech = aerotech.AerotechController(controller_ip="129.129.118.96")
self.__smargon = smargon.Smargon(beamline)
self.__ring_current_pv = PV(f"ARS07-DPCT-0100:CURR")
self.__dtz = MyMotor(f"{BEAMLINE}-ES-DET:TRZ")
self.__dty = MyMotor(f"{BEAMLINE}-ES-DET:TRY")
self.__det_cov = EnumPV(name="det_cov",
setpv=f"{BEAMLINE}-ES-DETCOV:SET",
getpv=f"{BEAMLINE}-ES-DETCOV:GET")
self.__sample_cam = epicsAD(f"{BEAMLINE}-ES-MS:")
self.__front_light = PredefinedPV(name='front_light',
setpv=f"{BEAMLINE}-ES-FL:SET",
getpv=f"{BEAMLINE}-ES-FL:SET",
predefs={"off":1.49,
'half':2.0,
'max':3.0},
timeout=10.0
)
self.__back_light = PredefinedPV(name='back_light',
setpv =f"{BEAMLINE}-ES-BL:SET",
getpv=f"{BEAMLINE}-ES-BL:SET",
predefs={"off": 0,
'half': 0.98,
'max': 1.2},
timeout=10.0
)
self.__back_light_pos = EnumPV(name = "back_light_pos",
setpv = f"{BEAMLINE}-ES-BL:POS-SET",
getpv = f"{BEAMLINE}-ES-BL:POS-GET",
timeout = 10.0)
self.__collimator_pos = MyMotor(f"{BEAMLINE}-ES-COL:TRY")
self.__collimator_X = MyMotor(f"{BEAMLINE}-ES-COL:TRX") # HOW TO HANDLE!!!
self.__scintillator_pos = PV(f"{BEAMLINE}-ES-SCL:TRY") # how to handle!!!
self.__scintillator_z = PV(f"{BEAMLINE}-ES-SCL:TRZ")
self.__beamstop_pos = EnumPV(name = "beamstop_pos",
setpv = f"{BEAMLINE}-ES-BS:POS-SET",
getpv = f"{BEAMLINE}-ES-BS:POS-GET",
timeout = 10.0)
self.__beamstop_x = MyMotor(f"{BEAMLINE}-ES-BS:TRX")
self.__beamstop_y = MyMotor(f"{BEAMLINE}-ES-BS:TRY")
self.__beamstop_z = MyMotor(f"{BEAMLINE}-ES-BS:TRZ")
self.__zoom = SetGetPV(name = f"zoom",
setpv = f"{BEAMLINE}-ES-MS:ZOOM.VAL",
getpv = f"{BEAMLINE}-ES-MS:ZOOM.RBV")
self.__cryojet_pos = EnumPV(name='cryojet_pos',
setpv = f"{BEAMLINE}-ES-CS:POS-SET",
getpv = f"{BEAMLINE}-ES-CS:POS-GET",
timeout = 10.0)
self.__cryojet_x = MyMotor(f"{BEAMLINE}-ES-CS:TRX") #currently in is 5 out is 15?
# Transmission
@property
def transmission(self) -> float:
return 1.0
def set_transmission(self, value: float, /, wait: bool = True):
pass
@transmission.setter
def transmission(self, value: float):
self.set_transmission(value, wait=False)
# Lamp light
@property
def lamp_light(self) -> float:
return self.__front_light.value
@lamp_light.setter
def lamp_light(self, v: float):
self.set_front_light(v, wait=False)
def set_front_light(self, v: float, /, wait: bool = True):
self.__front_light.move(v, wait=wait)
# Back light
@property
def back_light(self) -> float:
return self.__back_light.value
@back_light.setter
def back_light(self, v: float):
self.set_back_light(v, wait=False)
def set_back_light(self, v: float, /, wait: bool = True):
self.__back_light.move(v, wait=wait)
# Zoom
@property
def zoom(self) -> float:
return self.__zoom.value
@zoom.setter
def zoom(self, value: float):
self.set_zoom(value, wait=True)
def set_zoom(self, value: float, /, wait: bool = True):
self.__zoom.move(value, wait=wait)
# Collimator
@property
def collimator(self) -> float:
return self.__collimator_pos.get()
@collimator.setter
def collimator(self, value: float):
self.set_collimator(value, wait=True)
def set_collimator(self, value: float, /, wait: bool = True):
self.__collimator_pos.move(value, wait=wait)
# Scintillator
@property
def scintillator(self) -> float:
return self.__scintillator_pos.get()
@scintillator.setter
def scintillator(self, value: float):
self.set_scintillator(value, wait=True)
def set_scintillator(self, value: float, /, wait: bool = True):
self.__scintillator_pos.put(value, wait=wait)
# Reflector (backlight?)
@property
def reflector_up(self) -> bool:
return self.__back_light_pos.position.upper() == StagePositionEnum.MEASURE.name
@reflector_up.setter
def reflector_up(self, value: StagePositionEnum):
self.set_reflector_up(value, wait=True)
def set_reflector_up(self, value: StagePositionEnum, /, wait: bool = True):
self.__back_light_pos.move(value, wait=wait)
# Beamstop
@property
def beamstop_stage_up(self) -> bool:
return False
@beamstop_stage_up.setter
def beamstop_stage_up(self, value: bool):
pass
@property
def beamstop_z(self) -> float:
return 35.0
@beamstop_z.setter
def beamstop_z(self, value: float):
pass
# Optics
@property
def energy_kev(self) -> float:
return 12.4
@property
def ring_current(self) -> float:
return max(0.0, 0.0)
@property
def flux(self) -> float:
return 0
@property
def full_flux(self) -> float:
return 0
# Cryojet
@property
def cryojet_temp(self) -> float:
return 100
def anneal(self, time: float):
pass
@property
def cryojet_pos(self) -> StagePositionEnum:
return StagePositionEnum(self.__cryojet_pos.position.upper())
@cryojet_pos.setter
def cryojet_pos(self, value: StagePositionEnum):
self.cryojet_pos_setter(value, wait=True)
def cryojet_pos_setter(self, value: StagePositionEnum, wait:bool=False):
self.__cryojet_pos.move(value, wait=wait)
# Shutter
@property
def shutter(self) -> bool:
return False
@shutter.setter
def shutter(self, opened: bool):
pass
# Sample camera
@property
def samcam_settings(self) -> SampleCameraSettings:
return SampleCameraSettings(
gain=self.__sample_cam.gain_rbv.value,
exposure=self.__sample_cam.expo_rbv.value
)
@samcam_settings.setter
def samcam_settings(self, settings: SampleCameraSettings):
self.__sample_cam.setup(settings.gain, settings.exposure)
def samcam_get_image(self, /, gray: bool = False) -> np.ndarray:
return self.__sample_cam.get_image(gray=gray)
def samcam_auto(self, state: AutoEnum):
self.__sample_cam.set_auto(state)
def samcam_frame_id(self) -> int:
"""
Camera UniqueId for the last produced frame (monotonic counter from AreaDetector).
"""
return int(self.__sample_cam.uid.get())
# Detector Z
@property
def dtz(self) -> float:
return self.__dtz.readback
@dtz.setter
def dtz(self, value: float):
self.set_dtz(value, wait=True)
def set_dtz(self, value: float, /, wait: bool = True):
self.__dtz.move(value, wait=wait)
@property
def dtz_low(self) -> float:
return self.__dtz.get("LLM")
@property
def dtz_high(self) -> float:
return self.__dtz.get("HLM")
# Aertoech Automation1
@property
def aerotech_pos(self) -> Coordinate:
return Coordinate(x=self.__aerotech.gmx.readback,
y=self.__aerotech.gmy.readback, z=self.__aerotech.gmz.readback)
@aerotech_pos.setter
def aerotech_pos(self, pos: Coordinate):
# self.__aerotech.gmx.move(pos.x, wait=False)
# self.__aerotech.gmy.move(pos.y, wait=False)
# self.__aerotech.gmz.move(pos.z, wait=False)
self.__aerotech.gmx.move(pos.x, wait=True)
self.__aerotech.gmy.move(pos.y, wait=True)
self.__aerotech.gmz.move(pos.z, wait=True)
#TODO add wait pos?
@property
def aerotech_omega(self) -> float:
return self.__aerotech.omega.readback
@aerotech_omega.setter
def aerotech_omega(self, val: float):
self.set_aerotech_omega(val, wait=True)
def set_aerotech_omega(self, val: float, /, wait: bool = True):
self.__aerotech.omega.move(val, wait=wait)
@property
def aerotech_lock(self) -> bool:
return False
@aerotech_lock.setter
def aerotech_lock(self, val: bool):
pass
def aerotech_stop(self):
pass
# Smargon goniometer
@property
def smargon_pos(self) -> SmargonCoordinate:
return self.__smargon.readback
def set_smargon_pos(self, pos: SmargonCoordinate, /, wait: bool = True):
self.__smargon.target = pos
@smargon_pos.setter
def smargon_pos(self, pos: SmargonCoordinate):
self.set_smargon_pos(pos, wait=True)
def smargon_wait(self, timeout: float = 10.0):
self.__smargon.wait(timeout=timeout)
def smargon_move_home(self):
self.__smargon.move_home(wait=True)
def smargon_aerotech_wait(self):
self.__smargon.wait_aerotech(timeout=10.0)
if __name__ == "__main__":
from aare.common.beamline import mx_beamline
beamline = mx_beamline()
devs = BeamlineDevices(beamline)
print(devs.aerotech_pos)
# devs.aerotech_pos = Coordinate(x=124.0, y=1.0, z=1.0)
# print(devs.aerotech_pos)
# devs.aerotech_omega = 0.0
print(devs.reflector_up)
devs.reflector_up = StagePositionEnum.MEASURE
@@ -1,24 +1,41 @@
from typing import Tuple, Optional, Iterable
from enum import Enum
from typing import Optional, Iterable
import cv2
import requests
from aaredaqlib.models import MLBoxModel, MLOutputModel, MLBoxType, BoundingBoxModel
from aaredaqlib.logger_config import setup_logger
from aare.common.beamline import MXBeamline
from aare.common.models import MLBoxModel, MLOutputModel, MLBoxType, BoundingBoxModel
from aare.common.logger_config import setup_logger
logger=setup_logger("aareDAQ")
class BoxClassEnum(Enum):
"""Enum for MLBoxType values, should be updated if model changes
Loop_all = 0. Green box on camera
Pin = 1. Red box on camera
Crystal = 2. Blue box on camera
Loop_face = 3. Yellow box on camera
"""
Loop_all = 0
Pin = 1
Crystal = 2
Loop_face = 3
class MlBox:
def __init__(self, url="http://mx-aare-test.psi.ch:8002/predict/?model=best_v8_20102025.pt"): #mx-aare-test.psi.ch, mx-ml.psi.ch
self.__url = url
# self.class_info = [
# ["loop_all", (255, 0, 0)], # class 0: Blue for loop_all
# ["pin", (0, 255, 0)], # class 1: Green for pin
# ["crystal", (0, 0, 255)], # class 2: Red for crystal
# ["loop_face", (255, 255, 0)] # class 3: Yellow for loop_face
# ]
def __init__(self, bl:MXBeamline, url="http://mx-aare-test.psi.ch:8002/predict/?model=best_v8_20102025.pt"): #mx-aare-test.psi.ch, mx-ml.psi.ch
if bl == MXBeamline.SIMULATED:
self.__url = None
elif bl == MXBeamline.X06DA:
self.__url = "http://mx-aare-test.psi.ch:8002/predict/?model=best_v8_20102025.pt"
elif bl == MXBeamline.X10SA:
self.__url = "http://x10sa-spark-01.psi.ch:8002/predict/?model=best_v12_22092025.engine"
elif bl == MXBeamline.X06SA:
self.__url = ""
raise NotImplemented(f"MLBox not implemente for {bl}")
else:
raise Exception(f"unknown beamline {bl}")
def get_response(self, image):
ok, buf = cv2.imencode(".jpg", image)
@@ -1,34 +1,42 @@
import asyncio
import hmac
import io
import os, time
from typing import Tuple, AsyncGenerator
from typing import AsyncGenerator
import json
import cv2
import urllib3
import uvicorn
from aaredaqlib.coordinate import SmargonCoordinate, Coordinate
from aaredaqlib.logger_config import setup_logger
from aaredaqlib.models import SampleShortInfo, DAQStatusModel, BeamlineStateEnum, BeamlineSettingsModel, \
from aare.common.coordinate import SmargonCoordinate, Coordinate
from aare.common.error_codes import export_error_codes, export_error_codes_grouped
from aare.common.logger_config import setup_logger
from aare.common.models import SampleShortInfo, DAQStatusModel, BeamlineStateEnum, BeamlineSettingsModel, \
SampleShortInfoList, SessionStatus, SampleCameraSettings, AutofocusSettings, TokenData, \
CryojetSettingsModel, SimpleScanParameters, CrystalSize, FluorescenceSpectrumParameterModel, \
FluorescenceSpectrumOutputModel
from aaredaqlib.raster_grid import RasterGridRequest, CompletedRasterGrid
from aaredaqlib.rotation_scan import RotationScanRequest, CompletedRotationScan
from aaredaqlib.sample_geometry import SampleGeometryModel
FluorescenceSpectrumOutputModel, RecoveryActionRequest
from aare.common.raster_grid import RasterGridRequest, CompletedRasterGrid
from aare.common.rotation_scan import RotationScanRequest, CompletedRotationScan
from aare.common.sample_geometry import SampleGeometryModel
from fastapi import FastAPI, Depends
from fastapi import HTTPException
from fastapi import status as api_status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from starlette.responses import StreamingResponse
from urllib3.exceptions import InsecureRequestWarning
from aaredaq import auth
from aaredaqlib.beamline import mx_beamline
from aaredaq.config import BeamlineConfig
from aaredaq.daq import (AareDAQ, LoopCenteringFailed, TransformationInvalidException,
MountingFailed, WarningTellException, CriticalTellException)
from aare.daq import auth
from aare.common.beamline import mx_beamline
from aare.daq.config import BeamlineConfig
from aare.daq.daq import AareDAQ
from aare.daq.server_exception_handler import register_exception_handlers
from aare.common.exception_handler import (
SampleException,
UserRightsException,
)
logger = setup_logger("aareDAQ")
app = FastAPI()
register_exception_handlers(app)
# OAuth2 setup
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@@ -37,56 +45,119 @@ bl = mx_beamline()
cfg = BeamlineConfig(bl)
daq = AareDAQ(cfg, bl)
try:
daq.sync_current_sample_from_tell(force=True)
except Exception as e:
logger.warning(f"Initial sample sync from TELL failed: {e}")
_all_pgroups_cache: dict[str, tuple[list[str], float]] = {}
_ALL_PGROUPS_TTL_S = 60.0 # adjust TTL as needed
logger = setup_logger("aareDAQ")
_face_detection_state: dict = {
"seq": 0,
"running": False,
"samples": [],
"height_fit": {},
"area_fit": {},
}
_face_detection_state_lock = asyncio.Lock()
def _required_recovery_code() -> str:
code = os.getenv("AARE_RECOVERY_CODE", "").strip()
if not code:
logger.error("AARE_RECOVERY_CODE is not configured.")
raise HTTPException(
status_code=api_status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Recovery confirmation code is not configured on the server.",
)
return code
def _validate_recovery_code(confirmation_code: str) -> None:
expected = _required_recovery_code()
provided = str(confirmation_code or "").strip()
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.",
)
def _sample_is_mounted() -> bool:
try:
return daq.sample is not None
except Exception:
return False
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,
}
except Exception as e:
logger.warning(f"Failed to update face detection progress: {e}")
async def face_detection_event_stream() -> AsyncGenerator[str, None]:
last_seq = -1
try:
while True:
state = dict(_face_detection_state)
seq = int(state.get("seq", 0))
if seq != last_seq:
last_seq = seq
yield f"data: {json.dumps(state, separators=(',', ':'))}\n\n"
await asyncio.sleep(0.15)
except asyncio.CancelledError:
return
daq.set_face_detection_progress_callback(_push_face_detection_progress)
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
data = auth.authenticate_user(cfg, form_data)
return {"access_token": data, "token_type": "bearer"}
@app.get("/meta/error-codes")
async def meta_error_codes() -> dict[str, dict[str, str]]:
"""
Public, stable registry of machine-readable error codes.
Useful for GUIs, tests, and diagnostics.
"""
return export_error_codes_grouped()
@app.get("/status")
async def status(token: str = Depends(oauth2_scheme)) -> DAQStatusModel:
data = auth.parse_token(token)
try:
full = daq.status
active_pgroup = cfg.pgroup
is_staff = data.staff
in_allowed_groups = (active_pgroup is not None and active_pgroup in data.pgroups)
in_ro = is_staff or in_allowed_groups
sample_pgroup = full.sample.user if full.sample is not None else None
sample_view_allowed = sample_pgroup in data.pgroups or is_staff
full.sample = full.sample if in_ro and sample_view_allowed else None
full.box = full.box if in_ro else None
full.last_best_res = full.last_best_res if in_ro else None
full.last_best_b_factor = full.last_best_b_factor if in_ro else None
full.crystal_size = full.crystal_size 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
)
return full
except Exception as e:
logger.error(f"Error getting status: {e}")
raise HTTPException(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error getting status: {e}"
)
full = daq.status
active_pgroup = cfg.pgroup
is_staff = data.staff
in_allowed_groups = (active_pgroup is not None and active_pgroup in data.pgroups)
in_ro = is_staff or in_allowed_groups
sample_pgroup = full.sample.user if full.sample is not None else None
sample_view_allowed = sample_pgroup in data.pgroups or is_staff
full.sample = full.sample if in_ro and sample_view_allowed else None
full.box = full.box if in_ro else None
full.last_best_res = full.last_best_res if in_ro else None
full.last_best_b_factor = full.last_best_b_factor if in_ro else None
full.crystal_size = full.crystal_size 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
)
return full
@app.get("/beamline/geometry")
async def sample_geometry(
token: str = Depends(oauth2_scheme),
) -> SampleGeometryModel:
auth.check_jwt_ro(cfg, auth.parse_token(token))
return daq.sample_geometry
return daq.status.geom
@app.put("/beamline/omega")
@@ -96,14 +167,26 @@ async def omega(val: float, token: str = Depends(oauth2_scheme)):
daq.omega = val
return "OK"
@app.put("/beamline/light")
async def light(val: float, token: str = Depends(oauth2_scheme)):
logger.debug(f"Setting light to {val}")
@app.put("/beamline/omega_rel")
async def omega(val: float, token: str = Depends(oauth2_scheme)):
logger.debug(f"Moving omega by {val}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.light = val
daq.omega_rel(val)
return "OK"
@app.put("/beamline/front_light")
async def front_light(val: float, token: str = Depends(oauth2_scheme)):
logger.debug(f"Setting light to {val}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.front_light = val
return "OK"
@app.put("/beamline/back_light")
async def back_light(val: float, token: str = Depends(oauth2_scheme)):
logger.debug(f"Setting back light to {val}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.back_light = val
return "OK"
@app.put("/beamline/zoom")
async def zoom(val: float, token: str = Depends(oauth2_scheme)):
@@ -190,7 +273,7 @@ async def samcam_settings(s: SampleCameraSettings, token: str = Depends(oauth2_s
async def samcam_autofocus(s: AutofocusSettings, token: str = Depends(oauth2_scheme)):
logger.debug(f"SamCam AutoFocus")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.autofocus(s)
daq.auto_focus(s)
return "OK"
@app.post("/beamline/shutter")
@@ -251,41 +334,14 @@ async def mount(dbid: int, token: str = Depends(oauth2_scheme), reference: bool
index = i
if index == -1:
raise HTTPException(
status_code=api_status.HTTP_404_NOT_FOUND,
detail="Sample not found",
)
if token_data.staff or st.s[index].user in token_data.pgroups:
try:
daq.sample = st.s[index]
except MountingFailed as e:
raise HTTPException(
status_code=api_status.HTTP_404_NOT_FOUND,
detail=f"{e}",
)
except WarningTellException as e:
raise HTTPException(
status_code=api_status.HTTP_410_GONE,
detail=f"{e}",
)
except CriticalTellException as e:
raise HTTPException(
status_code=api_status.HTTP_417_EXPECTATION_FAILED,
detail=f"{e}"
)
except Exception as e:
raise HTTPException(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"{e}"
)
return "OK"
else:
raise HTTPException(
status_code=api_status.HTTP_401_UNAUTHORIZED,
detail="Sample belongs to a different user.",
headers={"WWW-Authenticate": "Bearer"},
)
raise SampleException(message="Sample not found")
if not (token_data.staff or st.s[index].user in token_data.pgroups):
raise UserRightsException(message="Sample belongs to a different user.")
daq.sample = st.s[index]
return "OK"
@app.post("/sample/unmount")
async def unmount(token: str = Depends(oauth2_scheme)):
@@ -303,6 +359,16 @@ async def manual(s: SampleShortInfo, token: str = Depends(oauth2_scheme)):
daq.create_sample(s)
print(f"DB ID after creating {s.db_id}")
@app.post("/sample/resync")
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.",
}
def get_spreadsheet(data: TokenData) -> SampleShortInfoList:
if data.staff:
@@ -392,6 +458,116 @@ async def beam_location(token: str = Depends(oauth2_scheme)):
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.state = BeamlineStateEnum.BeamLocation
@app.post("/state/maintenance")
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.")
return "OK"
@app.post("/access/take_over_beamline")
async def take_over_beamline(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> str:
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
_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)},
)
return "OK"
@app.post("/state/free_beamline")
async def free_beamline(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> str:
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
_validate_recovery_code(payload.confirmation_code)
cfg.state_busy = False
logger.warning(
"Beamline busy flag cleared via protected endpoint.",
extra={"session": getattr(data, "session", None)},
)
return "OK"
@app.post("/recovery/recover_beamline")
async def recover_beamline(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> dict:
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
_validate_recovery_code(payload.confirmation_code)
sample_mounted = _sample_is_mounted()
prev_state = cfg.state
prev_busy = cfg.state_busy
auth.force_current_sesion(cfg, data)
cfg.state_busy = False
cfg.state = BeamlineStateEnum.Maintenance
logger.warning(
"Beamline recovery action executed.",
extra={
"session": getattr(data, "session", None),
"previous_state": getattr(prev_state, "name", str(prev_state)),
"previous_busy": prev_busy,
"sample_mounted": sample_mounted,
},
)
return {
"ok": True,
"sample_mounted": sample_mounted,
"previous_state": getattr(prev_state, "name", str(prev_state)),
"previous_busy": prev_busy,
"new_state": BeamlineStateEnum.Maintenance.name,
}
@app.post("/recovery/unmount_sample")
async def recovery_unmount_sample(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> dict:
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
_validate_recovery_code(payload.confirmation_code)
auth.force_current_sesion(cfg, data)
if cfg.state_busy:
raise HTTPException(
status_code=api_status.HTTP_409_CONFLICT,
detail="Beamline is busy. Clear or recover the beamline before attempting recovery unmount.",
)
status = daq.status
if not getattr(status, "tell_connected", False):
raise HTTPException(
status_code=api_status.HTTP_409_CONFLICT,
detail=f"TELL is not connected: {getattr(status, 'tell_error', 'unknown error')}",
)
sample_mounted = _sample_is_mounted()
if not sample_mounted:
return {
"ok": True,
"sample_mounted": False,
"message": "No sample appears to be mounted.",
}
prev_state = cfg.state
daq.recovery_unmount_sample()
logger.warning(
"Recovery sample unmount executed.",
extra={
"session": getattr(data, "session", None),
"previous_state": getattr(prev_state, "name", str(prev_state)),
},
)
return {
"ok": True,
"sample_mounted": True,
"previous_state": getattr(prev_state, "name", str(prev_state)),
"new_state": getattr(cfg.state, "name", str(cfg.state)),
"message": "Recovery unmount completed.",
}
# Scans
@app.post("/scan/raster")
async def raster(val: RasterGridRequest, auto: bool = False, token: str = Depends(oauth2_scheme)) -> CompletedRasterGrid:
@@ -407,40 +583,7 @@ async def rotation(val: RotationScanRequest, token: str = Depends(oauth2_scheme)
@app.post("/scan/auto")
async def auto(s: SampleShortInfo, token: str = Depends(oauth2_scheme)):
auth.check_jwt_rw(cfg, auth.parse_token(token))
try:
runtime = daq.measure(s)
return f"{runtime:0.3f}"
except LoopCenteringFailed as e:
raise HTTPException(
status_code=api_status.HTTP_404_NOT_FOUND,
detail=f"Loop centering failed: {e}",
)
except TransformationInvalidException as e:
raise HTTPException(
status_code=api_status.HTTP_400_BAD_REQUEST,
detail=f"Transformation invalid: {e}",
)
except MountingFailed as e:
raise HTTPException(
status_code=api_status.HTTP_404_NOT_FOUND,
detail=f"{e}",
)
except WarningTellException as e:
raise HTTPException(
status_code=api_status.HTTP_410_GONE,
detail=f"{e}",
)
except CriticalTellException as e:
raise HTTPException(
status_code=api_status.HTTP_417_EXPECTATION_FAILED,
detail=f"{e}"
)
except Exception as e:
logger.error(f"Exception in auto: {e}")
raise HTTPException(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"error {e}"
)
runtime = daq.measure(s)
return f"{runtime:0.3f}"
@@ -479,14 +622,34 @@ async def alc_ml_bounding_box(token: str = Depends(oauth2_scheme)) -> RasterGrid
auth.check_jwt_rw(cfg, auth.parse_token(token))
return daq.ml_bounding_box()
@app.post("/face_detection/run")
async def face_detection_run(steps: int, step_size: int, token: str = Depends(oauth2_scheme)) -> dict:
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": {},
})
result = daq.face_detection(steps=steps, step_size=step_size)
return result
@app.get("/sse/face_detection")
async def sse_face_detection(token: str = Depends(oauth2_scheme)):
auth.check_jwt_ro(cfg, auth.parse_token(token))
return StreamingResponse(
face_detection_event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control"
}
)
# Access management
@app.get("/access/pgroup")
async def pgroup(token: str = Depends(oauth2_scheme)) -> str:
@@ -670,6 +833,17 @@ 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),
) -> str:
data = auth.parse_token(token)
auth.check_jwt_rw(cfg, data)
daq.send_screenshot_db(filename=filename, message=message)
return "OK"
LOGGING_CONFIG = {
"version": 1,
@@ -708,7 +882,7 @@ def main():
urllib3.disable_warnings()
# Run the application using uvicorn
uvicorn.run("aaredaq.server:app", host="0.0.0.0", port=5210, workers=4, log_config=LOGGING_CONFIG)
uvicorn.run("aare.daq.server:app", host="0.0.0.0", port=5210, workers=4, log_config=LOGGING_CONFIG)
if __name__ == "__main__":
+154
View File
@@ -0,0 +1,154 @@
# aare/common/server_error_handler.py
from __future__ import annotations
from fastapi import HTTPException
from fastapi import status as api_status
from starlette.requests import Request
from starlette.responses import JSONResponse
from aare.common.logger_config import setup_logger
from aare.common.exception_handler import (
MountingFailed,
WarningTellException,
CriticalTellException,
LoopCenteringFailed,
TransformationInvalidException,
BeamlineBusyException,
AuthenticationException,
SampleException,
UserRightsException,
SmargonCommunicationError, TellCommunicationError
)
logger = setup_logger("aareDAQ")
def _error_payload(*, code: str, message: str, extra: dict | None = None) -> dict:
payload = {"code": code, "message": message}
if extra:
payload["extra"] = extra
return payload
def register_exception_handlers(app) -> None:
"""
Register server-wide exception handlers on the given FastAPI app.
Call once right after `app = FastAPI()`.
"""
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
# Keep explicit HTTP errors, but normalize response shape
detail = exc.detail
if isinstance(detail, dict) and "code" in detail and "message" in detail:
body = detail
else:
body = _error_payload(code="HTTP_ERROR", message=str(detail))
return JSONResponse(status_code=exc.status_code, content=body, headers=exc.headers)
@app.exception_handler(MountingFailed)
async def mounting_failed_handler(request: Request, exc: MountingFailed) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_404_NOT_FOUND,
content=_error_payload(code="MOUNTING_FAILED", message=str(exc)),
)
@app.exception_handler(WarningTellException)
async def warning_tell_handler(request: Request, exc: WarningTellException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_410_GONE,
content=_error_payload(code="TELL_WARNING", message=str(exc)),
)
@app.exception_handler(CriticalTellException)
async def critical_tell_handler(request: Request, exc: CriticalTellException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_417_EXPECTATION_FAILED,
content=_error_payload(code="TELL_CRITICAL", message=str(exc)),
)
@app.exception_handler(LoopCenteringFailed)
async def loop_centering_failed_handler(request: Request, exc: LoopCenteringFailed) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_404_NOT_FOUND,
content=_error_payload(code="LOOP_CENTERING_FAILED", message=str(exc)),
)
@app.exception_handler(TransformationInvalidException)
async def transformation_invalid_handler(request: Request, exc: TransformationInvalidException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_400_BAD_REQUEST,
content=_error_payload(code="TRANSFORMATION_INVALID", message=str(exc)),
)
@app.exception_handler(BeamlineBusyException)
async def beamline_busy_handler(request: Request, exc: BeamlineBusyException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_423_LOCKED,
content=_error_payload(code="BEAMLINE_BUSY", message=str(exc) or "Beamline is busy"),
)
@app.exception_handler(AuthenticationException)
async def authentication_exception_handler(request: Request, exc: AuthenticationException) -> JSONResponse:
return JSONResponse(
status_code=getattr(exc, "status_code", api_status.HTTP_401_UNAUTHORIZED),
content=_error_payload(
code=str(getattr(exc, "code", "AUTHENTICATION_ERROR")),
message=str(exc) or "Invalid authentication",
),
headers=getattr(exc, "headers", None),
)
@app.exception_handler(UserRightsException)
async def user_rights_exception_handler(request: Request, exc: UserRightsException) -> JSONResponse:
return JSONResponse(
status_code=getattr(exc, "status_code", api_status.HTTP_403_FORBIDDEN),
content=_error_payload(
code=str(getattr(exc, "code", "FORBIDDEN")),
message=str(exc) or "Forbidden",
),
headers=getattr(exc, "headers", None),
)
@app.exception_handler(SampleException)
async def sample_exception_handler(request: Request, exc: SampleException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_404_NOT_FOUND,
content=_error_payload(code="SAMPLE_NOT_FOUND", message=str(exc) or "Sample not found"),
)
@app.exception_handler(SmargonCommunicationError)
async def smargon_comm_handler(request: Request, exc: SmargonCommunicationError) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_503_SERVICE_UNAVAILABLE,
content=_error_payload(
code="SMARGON_UNAVAILABLE",
message=str(exc) or "Smargon is unavailable",
extra={
"operation": getattr(exc, "operation", None),
"endpoint": getattr(exc, "endpoint", None),
},
),
)
@app.exception_handler(TellCommunicationError)
async def tell_comm_handler(request: Request, exc: TellCommunicationError) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_503_SERVICE_UNAVAILABLE,
content=_error_payload(
code="TELL_UNAVAILABLE",
message=str(exc) or "TELL is unavailable",
extra={
"operation": getattr(exc, "operation", None),
"endpoint": getattr(exc, "endpoint", None),
},
),
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception("Unhandled server exception")
return JSONResponse(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
content=_error_payload(code="INTERNAL_SERVER_ERROR", message=str(exc) or "Internal server error"),
)
@@ -2,12 +2,13 @@ import os
import json
import websocket
import time
from aareDBclient.models import PuckWithTellPosition
from aaredaqlib.models import SampleShortInfoList, SampleShortInfo, DewarAddress
from aareDB.models import PuckWithTellPosition
from aare.common.models import SampleShortInfoList, SampleShortInfo, DewarAddress
from config import BeamlineConfig
from aaredaqlib.beamline import MXBeamline
from aare.common.beamline import MXBeamline, mx_beamline
SLOT_IDENTIFIER = "X10SA"
beamline = mx_beamline()
SLOT_IDENTIFIER = beamline.value.upper()
WS_URL = f"wss://mx-db-01.psi.ch/dispatcher/protected_router/tell_runner/ws/samples-spreadsheet/{SLOT_IDENTIFIER}"
# Ensure the environment variable for the shared password is set
@@ -16,9 +17,8 @@ if not password:
raise ValueError("The AAREDB_SHARED_PASSWORD environment variable is not set.")
WS_HEADERS = [f"X-Shared-Password: {password}"]
beamline = MXBeamline.X10SA
config = BeamlineConfig(bl=beamline)
config = BeamlineConfig(bl=beamline)
# Cache the current spreadsheet for change detection
current_spreadsheet = None
@@ -1,25 +1,33 @@
import os
import json
import threading
import websocket
import sseclient
import requests
import time
from aareDBclient.models import PuckWithTellPosition
#from mxlibs3.tell_client import TellClient
#from aaredb import AareWrapper # Make sure the import path fits your project
#from aaredaqlib.beamline import MXBeamline
from aareDB.models import PuckWithTellPosition
from aare.devices.tell_client import TellClient
from aare.common.logger_config import setup_logger
from aaredb import AareWrapper # Make sure the import path fits your project
from aare.common.beamline import MXBeamline, mx_beamline
logger = setup_logger("aareDAQ")
# Configuration
SLOT_IDENTIFIER = "X10SA"
#WS_URL = f"wss://mx-db-01.psi.ch/dispatcher/protected_router/wstell/ws/slot/{SLOT_IDENTIFIER}"
WS_URL = f"wss://localhost:8001/protected_router/wstell/ws/slot/{SLOT_IDENTIFIER}"
beamline = mx_beamline()
SLOT_IDENTIFIER = beamline.value.upper()
WS_URL = f"wss://mx-db-01.psi.ch/dispatcher/protected_router/wstell/ws/slot/{SLOT_IDENTIFIER}"
#WS_URL = f"wss://localhost:8001/protected_router/wstell/ws/slot/{SLOT_IDENTIFIER}"
WS_HEADERS = [f"X-Shared-Password: {os.getenv('AAREDB_SHARED_PASSWORD')}"]
print(WS_HEADERS)
# Initialize TELL client and DB wrapper
beamline = None #MXBeamline.X06DA # Use your beamline enum/value
tell_client = None #TellClient(bl=beamline)
aare_db = None #AareWrapper(bl=beamline)
# Use your beamline enum/value
tell_client = TellClient(bl=beamline)
aare_db = AareWrapper(bl=beamline)
# Track current state
current_pucks = []
@@ -32,22 +40,17 @@ def listen_to_sse():
return
sse_url = tell_client.url + "/events"
try:
response = requests.get(sse_url, stream=True)
client = sseclient.SSEClient(response)
#response = requests.get(sse_url, stream=True)
client = sseclient.SSEClient(sse_url)
# Immediately fetch current detected pucks once on connect so the system starts with
# up-to-date state (instead of waiting for the first DewarContentUpdate event).
try:
print("[SSE][listen_to_sse] Initial detected pucks fetch on connect")
handle_tell_change_event()
except Exception as exc:
print(f"[SSE][listen_to_sse][WARN] Initial fetch failed: {exc}")
print("[SSE][listen_to_sse] Initial detected pucks fetch on connect")
handle_tell_change_event()
for event in client.events():
print(f"event = {event.event} with data: {event.data}")
if event.event == "DewarContentUpdate":
print(f"[SSE][listen_to_sse] event: {event.event}, data: {event.data}")
on_sse_event(event)
except requests.exceptions.RequestException as exc:
except Exception as exc:
print(f"[SSE][listen_to_sse][ERROR] Failed to connect to {sse_url}: {exc}")
def compare_and_report_change(old, new, key_func):
@@ -69,8 +72,8 @@ def compare_and_report_change(old, new, key_func):
def ws_update_samples_info(pucks):
"""Send sample info to TELL robot."""
#tell_client.set_samples_info(pucks)
print(pucks)
tell_client.set_samples_info(pucks)
logger.info(f"Payload sent to TELL: {pucks}")
def handle_tell_change_event():
"""Fetch the latest detected pucks from TELL and update the database."""
@@ -139,8 +142,8 @@ def on_open(ws):
def main():
# Start SSE listener in a separate background thread
#sse_thread = threading.Thread(target=listen_to_sse, daemon=True)
#sse_thread.start()
sse_thread = threading.Thread(target=listen_to_sse, daemon=True)
sse_thread.start()
# Main thread runs websocket client loop
while True:
+144
View File
@@ -0,0 +1,144 @@
from aare.common.coordinate import Coordinate
from aare.common.models import StagePositionEnum
from aare.daq.devices import BeamlineDevices
from aare.daq.config import BeamlineConfig, ABR_POS_MOUNT
def move_bsz(devs: BeamlineDevices, target: float):
if abs(target - devs.bsz.position) > 0.1:
beamstop_stage_measure = devs.beamstop_stage_up
reflector_measure = devs.reflector_up
devs.reflector_up = False
devs.beamstop_stage_up = True
devs.beamstop_z = target
if reflector_measure:
devs.reflector_up = True
if not beamstop_stage_measure:
devs.beamstop_stage_up = False
def wait_for_dc_devices(devs: BeamlineDevices):
# Should wait for DTZ
pass
def wait_for_se_devices(devs: BeamlineDevices, cfg: BeamlineConfig):
# Should wait for Cryojet go far
pass
def common_2rse(devs: BeamlineDevices, cfg: BeamlineConfig):
print('move collimator and scintillator to 20')
devs.collimator = 20.0
devs.scintillator = 20.0
print('move smargon home')
devs.smargon_move_home()
print('try to move aerotech')
devs.aerotech_pos = Coordinate(x=0.0, y=0.0, z=0.0)
devs.aerotech_omega = 0.0
#print('move bl to park')
#devs.reflector_up = StagePositionEnum.PARK
print('move bs to park')
devs.beamstop_stage_up = StagePositionEnum.PARK
#print('move cryo to park')
#devs.__cryojet_pos = StagePositionEnum.PARK
def m2se(devs: BeamlineDevices, cfg: BeamlineConfig):
pass
def sa2se(devs: BeamlineDevices, cfg: BeamlineConfig):
print('move collimator and scintillator to 20')
devs.collimator = 20.0
devs.scintillator = 20.0
print('move smargon home')
devs.smargon_move_home()
print('try to move aerotech')
devs.aerotech_pos = Coordinate(x=0.0, y=0.0, z=0.0)
devs.aerotech_omega = 0.0
print('move bl to park')
devs.reflector_up = StagePositionEnum.PARK
print('move bs to park')
devs.beamstop_stage_up = StagePositionEnum.PARK
#print('move cryo to park')
#devs.__cryojet_pos = StagePositionEnum.PARK
def sa2rse(devs: BeamlineDevices, cfg: BeamlineConfig):
common_2rse(devs, cfg)
def dc2rse(devs: BeamlineDevices, cfg: BeamlineConfig):
common_2rse(devs, cfg)
def se2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
devs.reflector_up = StagePositionEnum.MEASURE
devs.aerotech_pos = cfg.abr_meas_pos
def rse2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
se2sa(devs, cfg)
def sa2dc(devs: BeamlineDevices, cfg: BeamlineConfig):
pass
def dc2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
pass
def sa2xrf(devs: BeamlineDevices, cfg: BeamlineConfig):
"""sample alignment to XrfCollection"""
pass
def xrf2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
"""XrfCollection to sample alignment"""
pass
def sa2ws(devs: BeamlineDevices, cfg: BeamlineConfig):
pass
def ws2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
pass
def sa2ba(devs: BeamlineDevices, cfg: BeamlineConfig):
pass
def ba2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
pass
def sa2bl(devs: BeamlineDevices, cfg: BeamlineConfig):
devs.scintillator = 20.0
def bl2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
devs.scintillator = 20.0
def bl2ba(devs: BeamlineDevices, cfg: BeamlineConfig):
devs.scintillator = 20.0
def ba2bl(devs: BeamlineDevices, cfg: BeamlineConfig):
devs.scintillator = 20.0
def sa2dh(devs: BeamlineDevices, cfg: BeamlineConfig):
devs.scintillator = 20.0
if devs.tell.get_mounted_sample() is not None:
try:
# Best effort try to unmount
devs.tell.unmount(wait=True)
except Exception as e:
print(f"Error for unmounting: {e}")
devs.tell.dry(wait_cold=-1, wait=False)
def dh2sa(devs: BeamlineDevices, cfg: BeamlineConfig):
devs.scintillator = 20.0
devs.reflector_up = StagePositionEnum.MEASURE
if __name__ == "__main__":
from aare.common.beamline import mx_beamline
import time
bl = mx_beamline()
cfg = BeamlineConfig(bl)
devs = BeamlineDevices(bl)
common_2rse(devs, cfg)
time.sleep(10.0)
rse2sa(devs, cfg)
+386
View File
@@ -0,0 +1,386 @@
import os
import sys
import threading
import time
from enum import Enum
from typing import Union
import automation1 as a1
from epics import PV, Motor, caput, caget
from aare.common.beamline import MXBeamline, mx_beamline
from aare.devices.my_motor import MyMotor
class TaskEnum(Enum):
TASK_0 = 0
TASK_1 = 1
TASK_2 = 2
TASK_3 = 3
TASK_4 = 4
class AxisEnum(Enum):
X = "gmx"
Y = "gmy"
Z = "gmz"
OMEGA = "Omega"
class AerotechRunEnum(Enum):
STOP = 0
START = 1
RUN = 2
LOAD = 3
PAUSE = 4
RESET = 5
class VariableTypeEnum(Enum):
INT = 0
REAL = 1
STRING = 2
class AerotechControllerEpics:
def __init__(self, beamline: MXBeamline):
BEAMLINE = beamline.value.upper()
self.aerotech_pv_prefix = f"{BEAMLINE}-ES-DF1"
trx_prefix = f"{self.aerotech_pv_prefix}:TRX1"
try_prefix = f"{self.aerotech_pv_prefix}:TRY1"
trz_prefix = f"{self.aerotech_pv_prefix}:TRZ1"
rotu_prefix = f"{self.aerotech_pv_prefix}:ROTU"
self.gmx = MyMotor(trx_prefix)
self.gmy = MyMotor(try_prefix)
self.gmz = MyMotor(trz_prefix)
self.omega = MyMotor(rotu_prefix)
self.__enable_all = PV(f"{self.aerotech_pv_prefix}:EnableAll")
self.__disable_all = PV(f"{self.aerotech_pv_prefix}:DisableAll")
self.__acknowledge_all = PV(f"{self.aerotech_pv_prefix}:AckAll")
self.__stop_all = PV(f"{self.aerotech_pv_prefix}:StopAll")
self.__task_filename = PV(f"{self.aerotech_pv_prefix}:TASK:FILENAME")
self.__task_id = PV(f"{self.aerotech_pv_prefix}:TASK:TASKIDX") # values can be 1 to 4 DO NOT USE 0!!!!
self.__task_run_enum = PV(f"{self.aerotech_pv_prefix}:TASK:SWITCH") # 0 Stop, 1 Start, 2 Run,
# 3 Load, 4 Pause, 5 Reset
self.enable_all()
def enable_all(self):
self.__enable_all.put(1)
self.__enable_all.put(0)
def acknowledge_all(self):
self.__acknowledge_all.put(1)
self.__acknowledge_all.put(0)
def _disable_all(self):
self.__disable_all.put(1)
self.__disable_all.put(0)
def stop_all(self):
self.__stop_all.put(1)
self.__stop_all.put(0)
def __task_stop(self):
self.__task_run_enum.put(0)
def __task_start(self):
self.__task_run_enum.put(1)
def __task_run(self):
self.__task_run_enum.put(2)
def __task_pause(self):
self.__task_run_enum.put(4)
def __task_load(self):
self.__task_run_enum.put(3)
def __task_reset(self):
self.__task_run_enum.put(5)
def __set_task_id(self, task_id: int):
if task_id not in range(1, 5):
raise ValueError(f"Invalid task id {task_id}")
self.__task_id.put(task_id)
def __put(self, axis: MyMotor, attr: str, value: float):
try:
axis.put(attr=attr, value=value)
except Exception as e:
raise ValueError(f"Error setting {axis.name} {attr} to {value}: {e}")
def set_offset(self, axis: MyMotor, offset: float):
self.__put(axis=axis, attr="OFF", value=offset)
def home_all(self, task_id: int = 3):
#self.__task_id.put(2)
self.__task_reset()
time.sleep(0.2)
self.__task_id.put(task_id)
print(self.__task_id.get())
time.sleep(0.2)
self.__task_filename.put("home_all.a1exe")
print(self.__task_filename.get())
# self.__task_load()
#time.sleep(0.2)
#print(self.__task_run_enum.get())
time.sleep(1.0)
self.__task_run()
print(self.__task_run_enum.get())
def get_tast_status(self, task_id: int = 3):
task_status = PV(f"{self.aerotech_pv_prefix}:TASK:T{task_id}:STATUS")
return task_status.get(as_string=True)
def __set_global(self, index:int, value: Union[int, float, str],
timeout:float=10.0):
"""Set a global variable in aerotech, it takes ~200 ms for the value to be set
:param index: select a variable to change. an integer between 0..256 for int and real variable for 0..31 for strings
:param value: the value to set can be int, float (REAL) or string
:param timeout: timeout for vairable change, default 10.0 seconds """
var_type = self.__get_var_type(value)
caput(f"{self.aerotech_pv_prefix}:VAR:{var_type.upper()}-ADDR", index)
if var_type == 'STRING':
var_type = 'STRING-SHORT'
caput(f"{self.aerotech_pv_prefix}:VAR:{var_type.upper()}", value)
start = time.perf_counter()
while time.perf_counter() - start < timeout:
rbv = self.__read_global_from_index(index, var_type)
if rbv == str(value):
return
time.sleep(0.1)
raise TimeoutError(f"Timeout setting global variable {index} to {value}")
def __read_global_feedback(self, index, var_type: str):
caput(f"{self.aerotech_pv_prefix}:VAR:{var_type.upper()}-RBV.PROC", 1)
time.sleep(0.5)
return caget(f"{self.aerotech_pv_prefix}:VAR:{var_type.upper()}-RBV", as_string=True)
def __read_global_from_index(self, index, var_type: str):
return caget(f"{self.aerotech_pv_prefix}:VAR:{var_type.upper()}{index}_RBV", as_string=True)
def __get_var_type(self, value):
if type(value) is int:
return VariableTypeEnum.INT.name
elif type(value) is float:
return VariableTypeEnum.REAL.name
elif type(value) is str:
return VariableTypeEnum.STRING.name
else:
raise ValueError(f"Invalid type {type(value)} for global variable")
def get_global_variable(self, index: int, var_type:VariableTypeEnum):
return self.__read_global_from_index(index, var_type.name)
def set_global_variable(self, index: int, value: Union[int, float, str]):
self.__set_global(index, value)
class AerotechController:
def __init__(self, controller_ip: str):
if controller_ip is None:
self.controller = None
else:
self.controller = a1.Controller.connect(controller_ip)
self.status_item_configuration = a1.StatusItemConfiguration()
self.start_controller()
def start_controller(self):
self.controller.start()
def disconnect(self):
self.controller.disconnect()
def enable_motion(self, axis: str):
self.controller.runtime.commands.motion.enable(axis.upper())
def home_motor(self, axis: str):
self.__configure_axis_status(axis, a1.AxisStatusItem.AxisEnabled)
result = self.get_status_via_status_items(name=axis, status_item=a1.AxisStatusItem.AxisEnabled)
if not result == a1.AxisStatusItem.AxisEnabled:
self.enable_motion(axis)
self.controller.runtime.commands.motion.home(axis.upper())
def __configure_axis_status(self, axis_name: str, axis_status_item: a1.AxisStatusItem):
self.status_item_configuration.axis.add(axis_status_item=axis_status_item, axis=axis_name)
def __configure_task_status(self, task_id:int, task_status_item: a1.TaskStatusItem):
self.status_item_configuration.task.add(task_status_item=task_status_item, task=f"Task {task_id}")
def get_status_via_status_items(self, name: str | int, status_item: a1.TaskStatusItem | a1.AxisStatusItem):
result = self.controller.runtime.status.get_status_items(self.status_item_configuration)
if int(name):
return result.task.get(status_item, f"Task {name}").value
elif str(name):
return result.axis.get(status_item, name).value
else:
raise ValueError(f"Invalid status item {status_item} for task {name}")
def set_global_variable(self, index:int, value: Union[int, float, str]):
if type(value) is int:
self.controller.runtime.variables.global_.set_integer(index, value)
elif type(value) is float:
self.controller.runtime.variables.global_.set_real(index, value)
elif type(value) is str:
self.controller.runtime.variables.global_.set_string(index, value)
else:
raise ValueError(f"Invalid type {type(value)} for global variable")
def get_axis_status_via_status_items(self, axis_name:str, status_item: a1.AxisStatusItem):
result = self.controller.runtime.status.get_status_items(self.status_item_configuration)
return result.task.get(status_item, axis_name).value
def wait_for_status_to_change(self, name: str | int, status_item:a1.TaskStatusItem | a1.AxisStatusItem, enum, timeout: float = 60.0):
start = time.perf_counter()
while self.get_status_via_status_items(name, status_item) == enum:
time.sleep(0.1)
if time.perf_counter() - start > timeout:
print(self.get_status_via_status_items(name, status_item))
raise TimeoutError(f"Timeout waiting for task {name} to finish")
return
def wait_program_finish(self, task_id:int=3, timeout:float=60.0):
start = time.perf_counter()
status_item = a1.TaskStatusItem.TaskState
while True:
status = self.get_status_via_status_items(task_id, status_item)
if status != a1.TaskState.ProgramRunning:
if status == a1.TaskState.Idle:
return
elif status == a1.TaskState.ProgramComplete:
print(f"Program completed on Task {task_id}")
return
elif status == a1.TaskState.Error:
raise RuntimeError(f"Task {task_id} failed to start")
elif status == a1.TaskState.ProgramPaused:
print(f"Program paused on Task {task_id}, not sure how")
else:
raise RuntimeError(f"Unknown status {status} for task {task_id}")
if time.perf_counter() - start > timeout:
print(self.get_status_via_status_items(task_id, status_item))
raise TimeoutError(f"Timeout waiting for task {task_id} to finish")
time.sleep(0.05)
print(f"Task {task_id} finished with status {status}")
def __run_program(self, script_name:str, task_id:int=3, timeout:float=60.0):
self.__configure_task_status(task_id, a1.TaskStatusItem.TaskState)
state = self.get_status_via_status_items(task_id, a1.TaskStatusItem.TaskState)
print(f"Task {task_id} is in state {state}: {a1.TaskState(state).name}")
if state != a1.TaskState.ProgramComplete and state != a1.TaskState.Idle:
if a1.TaskState.ProgramRunning == state:
self.wait_for_status_to_change(task_id, a1.TaskStatusItem.TaskState, state, timeout)
elif a1.TaskState.ProgramComplete == state:
print('can continue')
else:
print(f"Task {task_id} is not Idle: {a1.TaskState(state).name} , aborting")
return
try:
print(f"Running script {script_name} on task {task_id}")
self.controller.runtime.tasks[task_id].program.run(script_name)
self.wait_program_finish(task_id, timeout)
except Exception as e:
print(f"Error executing script {script_name}: {e}")
def run_grid_scan(self, cell_height_mm:float, num_rows:int,
row_width_mm:float, time_per_row_s: float, task_id:int =3):
self.set_global_variable(0, cell_height_mm)
self.set_global_variable(1, row_width_mm)
self.set_global_variable(2, time_per_row_s)
self.set_global_variable(1, num_rows)
self.set_global_variable(0, 1)
#self.__run_program(script_name="grid_scan.a1exe", task_id=task_id, timeout=120.0)
def home_all(self, task_id:int = 3):
self.__run_program(script_name="home_all.a1exe", task_id=task_id, timeout=120.0)
def rotation_scan(self, task_id = 3, start_angle:float = 0.0, end_angle:float = 360.0, step_size:float = 1.0, num_steps:int = 10):
self.__run_program(script_name="rotation_scan.a1exe", task_id=task_id, timeout=90.0)
def move_motor_absolute(self, axis:str, position:float, speed:float=1.0):
self.controller.runtime.commands.motion.moveabsolute(axis.upper(), [position], [speed])
def move_motor_linear(self, axis: str, position: float, speed: float = 1.0):
self.controller.runtime.commands.motion.movelinear(axis.upper(), [position], speed)
if __name__ == "__main__":
beamline = mx_beamline()
print(beamline)
### test on 10S
aerotech = AerotechController(controller_ip="129.129.118.96")
#aerotech.enable_motion("X")
#aerotech.home_all()
rw = 0.320
ch = 0.010
nr = 10
tpr_s = rw / ch * 0.02
#aerotech.run_grid_scan(cell_height_mm=ch, num_rows=nr,
# row_width_mm=rw, time_per_row_s=tpr_s)
st = time.perf_counter()
aerotech.move_motor_absolute("Z", 0, 10000)
print(f"time to move: {time.perf_counter() - st}")
aerotech.disconnect()
#aerotech_epics = AerotechControllerEpics(beamline)
#aerotech_epics.omega.speed = 80.0
#print(aerotech_epics.get_global_variable(0, VariableTypeEnum.REAL))
#aerotech_epics.set_global_variable(0, 100.0)
#print(aerotech_epics.get_global_variable(0, VariableTypeEnum.REAL))
# print('enable all motors')
# aerotech_epics.enable_all()
# print('home_all')
# aerotech_epics.home_all()
# print('wait for home to finish')
# start = time.perf_counter()
# status = aerotech_epics.get_tast_status(2)
# print(f'current status: {status}')
# if status == 'Idle':
# time.sleep(0.2)
# test_counter = 0
# while status != 'Ready':
# time.sleep(0.1)
# if time.perf_counter() - start > 360.0:
# raise TimeoutError("Timeout waiting for home all task to finish")
# elif status == 'Idle':
# time.sleep(0.5)
# if test_counter == 1:
# raise RuntimeError("Home all task failed to start")
# time.sleep(1.0)
# print('restarting home all')
# aerotech_epics.home_all()
# time.sleep(1.0)
# test_counter = 1
#
# status = arotech_epics.get_tast_status(2)
# for i in range(10):
# print(f'moving to {(i+1)*90}')
# aerotech_epics.omega.move(90.0, relative=True, wait=True)
# time.sleep(1.0)
# if i == 5:
# print('simulating disable')
# aerotech_epics._disable_all()
# time.sleep(10.0)
# print('re-enabling')
# aerotech_epics.enable_all()
# time.sleep(1.0)
# print('home all')
# aerotech_epics.home_all()
# print('wait for home to finish')
# start = time.perf_counter()
# status = aerotech_epics.get_tast_status(2)
# print(f'current status: {status}')
# test_counter = 0
# while status != 'Ready':
# time.sleep(0.1)
# if time.perf_counter() - start > 360.0:
# raise TimeoutError("Timeout waiting for home all task to finish")
# status = aerotech_epics.get_tast_status(2)
# time.sleep(0.5)
# if test_counter == 1:
# raise RuntimeError("Home all task failed to start")
# time.sleep(1.0)
# print('restarting home all')
# aerotech_epics.home_all()
# time.sleep(1.0)
# test_counter = 1
#aerotech_epics.home_all()
@@ -1,6 +1,12 @@
from enum import Enum
import epics
import numpy as np
class AutoEnum(Enum):
MANUAL = 0
ONCE = 1
AUTO = 2
class epicsAD(object):
def __init__(self, prefix, cam="cam1:", image="image1:"):
@@ -12,10 +18,15 @@ class epicsAD(object):
self.acquire = epics.PV(prefix + cam + "Acquire")
self.color = epics.PV(prefix + cam + "ColorMode")
self.gain_mode = epics.PV(prefix + cam + "GainAuto")
self.gain_mode_rbv = epics.PV(prefix + cam + "GainAuto_RBV")
# 0 - manual, GainMode, 2 - auto GainAuto
self.expo_mode = epics.PV(prefix + cam + "ExposureAuto")
# 0 - manual, ExposureMode, 2 - auto ExposureAutoself.gain = epics.PV(prefix + cam + "Gain")
self.expo_mode_rbv = epics.PV(prefix + cam + "ExposureAuto_RBV")
# 0 - manual, ExposureMode, 1 - once 2 - auto ExposureAuto
self.gain = epics.PV(prefix + cam + "Gain")
self.gain_rbv = epics.PV(prefix + cam + "Gain_RBV")
self.expo = epics.PV(prefix + cam + "AcquireTime")
self.expo_rbv = epics.PV(prefix + cam + "AcquireTime_RBV")
self.ndim = epics.PV(prefix + image + "NDimensions_RBV")
self.dim0 = epics.PV(prefix + image + "ArraySize0_RBV")
@@ -128,10 +139,20 @@ class epicsAD(object):
self.expo.put(expo) # 20 hz acquisition
self.acquire.put(1)
def set_auto(self):
def set_auto(self, state: AutoEnum):
self.acquire.put(0, wait=True)
self.gain_mode.put(2) # automatic gain control
self.expo_mode.put(2) # automatic exposure control
self.gain_mode.put(state.value) # automatic gain control
self.expo_mode.put(state.value) # automatic exposure control
self.acquire.put(1)
def set_auto_once(self):
self.expo_mode.put(1)
self.gain_mode.put(1)
def set_manual(self):
self.acquire.put(0, wait=True)
self.gain_mode.put(0) # automatic gain control
self.expo_mode.put(0) # automatic exposure control
self.acquire.put(1)
def restore(self, gain: float = 5, expo: float = 0.025):
+259
View File
@@ -0,0 +1,259 @@
from bec_lib.client import BECClient
from bec_ipython_client import BECIPythonClient
from bec_lib.service_config import ServiceConfig
from bec_lib.procedures.helper import FrontendProcedureHelper, BackendProcedureHelper
from aare.common.beamline import MXBeamline, mx_beamline
#from pxii_bec.macros.pxii_guards import GuardViolation
#specify up to 10 queue to runs in parallel, request more if needed!
# st = client.proc.request_new("sleep", ((), {"time_s":5}), queue="test")
#to see all deevices
#devs.show_all
#helper fucntions
# helper.get.active_and_pending_queue_names()
# helper.get.running_procedures()
# helper.request.abort_queue()
def bec_exception_handler(exception: Exception):
print(f"Exception: {exception}")
class MultiPositionDevice:
"""inherits from the MultiPositionDevice BEC class
can move in and out
:param device is inisitialised with initialise_PD_devices() and can be called with PD.device_name
:states is a dictionary of str, float from bec.dev.device_name.user_parameter
"""
def __init__(self, device, states: dict[str, float] | None = None):
self.device = device
self.states = states
def move_to(self, position: str):
self.device.move_to(position)
@property
def actual(self) -> float:
return self.device.actual
@property
def state(self) -> str:
"""returns the current state of the device as a string or unknown if out of a valid state position"""
return self.device.state
@property
def is_at(self, state:str) -> bool:
return self.device.is_at("state")
@property
def is_clear(self) -> bool:
return self.device.is_clear()
class PositionedDevice:
"""inherits from the PositionedDevice BEC class
can move in and out
:param device is inisitialised with initialise_PD_devices() and can be called with PD.device_name
"""
def __init__(self, device):
self.device = device
self.states = ["in", "out"]
def move_in(self):
self.device.mvin()
def move_out(self):
self.device.mvout()
@property
def is_in(self) -> bool:
return self.device.is_in()
@property
def is_out(self) -> bool:
return self.device.is_out()
class GuardedPositionedDevice:
"""inherits from the GuardedAxis BEC class
:param device is inisitialised with initialise_PD_devices() and can be called with PD.device_name
allowed functions are move(float) and actual which returns the feedback"""
def __init__(self, device):
self.device = device
def move(self, position: float):
self.device.move(position)
@property
def actual(self) -> float:
return self.device.actual
class OurBECDevices:
def __init__(self, BECDevices):
print("initialising devices")
init_positioned_devices()
dev = BECDevices
print("devices initialised")
self.coll_y = MultiPositionDevice(device = PD.coll_y,
states = dev.coll_y.user_parameter)
self.scintillator_diode_y = MultiPositionDevice(device = PD.diag_y,
states = dev.diag_y.user_parameter)
self.backlight_pos = PositionedDevice(device = PD.bl_pos)
#self.beamstop_pos = PositionedDevice(device = PD.bs_pos)
self.beamstop_z = GuardedPositionedDevice(device = PD.bs_z)
self.goniometer_x = GuardedPositionedDevice(device = PD.gon_x) # may get deprecated with the IOC
class BECClientWorker:
def __init__(self, beamline:MXBeamline, name:str = "default"):
BEAMLINE = beamline.value.lower()
service_config = ServiceConfig(redis={"host": f"{BEAMLINE}-bec-001.psi.ch", "port": 6379})
print(service_config.config)
service_config.config["log_writer"]["base_path"]='./logs'
print(service_config.config)
self.client = BECIPythonClient(config=service_config)
#self.client.config.update_session_with_file("/sls/x10sa/config/bec/production/bec/bec_lib/bec_lib/config_helper.py")
self.client.start()
self.dev = self.client.device_manager.devices
self.scans = self.client.scans
self.macros = self.client.macros
self.load_user_macros()
self.helper = FrontendProcedureHelper(self.client.connector)
self.ProtectedDevices = OurBECDevices(BECDevices=self.dev)
def run_macro(self, macro_name:str, *args, queue:str = "default", **kwargs):
return self.client.proc.run_macro(macro_name, *args, queue=queue)
def run_macro_blocked(self, macro_name:str, *args, queue:str = "default", **kwargs):
try:
status = self.run_macro(macro_name, *args, queue=queue)
print(status)
status.wait()
print(status)
return status
except Exception as e:
print(f"Error: {e}")
return None
def show_all_devices(self):
return self.dev.show_all
def list_all_macros(self):
return self.macros.list_user_macros()
def load_user_macros(self):
self.macros.load_all_user_macros()
def shutdown_client(self):
self.client.shutdown()
def common2rse(self):
status = self.scans.umv(self.dev.blight_pos, 0, relative=False)
try:
SE.blpos.checkpos()
except NotImplemented as e:
print(f"Backlight position not checked: {e}")
print(f"Moved backlight to position {self.dev.blight_pos.read()} with status {status.status}")
self.scans.umv(self.dev.bs_z, 72.0, self.dev.coll_y, 20.0, self.dev.scin_y, 20.0, self.dev.cryo_pos, 1, relative=False)
self.scans.umv(self.dev.bs_pos, 0, relative=False)
print(f"Moved everything else to park position with status {status.status}")
if self.dev.bs_z.read().value != 72.0:# or self.dev.coll_y.read() != 20.0 or self.dev.scin_y.read() != 20.0:
print(f"is bs_z correct: {self.dev.bs_z.read().value != 72.0}")# is coll_y correct {self.dev.coll_y.read() != 20.0}, is scin_y correct {self.dev.scin_y.read() != 20.0}")
raise Exception("Error: something went wrong")
def rse2sa(self):
self.scans.umv(self.dev.blight_pos, 1, relative=False)
self.scans.umv(self.dev.bs_pos, 1, relative = False)
def mono_pitch_scan_runner(self):
print('this is a print statement from inside DAQ not BEC')
try:
self.macros.mono_pitch_scan(False)
except Exception as e:
print(f"Error: {e}")
# not implemented yet
# self.cryo_pos = PD.cryo_pos
# self.xrf_pos = PD.xrf_po
if __name__ == "__main__":
import time
print('starting BEC Client')
beamline = mx_beamline()
try:
client = BECClientWorker(beamline)
except Exception as e:
print(f"Error: {e}")
import sys
sys.exit(1)
client.show_all_devices()
client.list_all_macros()
try:
# a=client.a2e_runner(160, "iln")
# print(a)
# print(convert_from_energy(12))
# energy = get_current_energy()
# pos = get_dcm_motors_positions(energy)
# print(energy, pos)
print(bs_z_policy(15.0))
#client.scans.umv(client.dev.xeye_x, 0, relative=False)
#client.mono_pitch_scan_runner()
#client.mono_pitch_scan_runner()
#b=client.run_macro_blocked("a2e", 160, "iln", queue="test")
#b = client.run_macro_blocked("mono_pitch_scan", False, queue="default")
#status = client.mono_pitch_scan_runner
#print(status)
#client.rse2sa()
#time.sleep(10.0)
#time.sleep(10.0)
#print(status)
#client.common2rse()
#print(status)
except GuardViolation as e:
print(f"GuardViolation: {e}")
except RuntimeError as e:
print(f"RuntimeError: {e}")
except Exception as e:
print(f"Error: {e}")
client.shutdown_client()
#
# try:
# st = client.proc.run_macro("a2e", 160, "iln", queue="test")
# print(st)
# st.wait()
# print(st)
# status_1 = scans.umv(bec_dev.bs_x, -1.0, bec_dev.bs_y, -1.0, relative = True) # blocking
# print(
# f"Moved to position {bec_dev.bs_x.position} with status {status.status}"
# )
# status = scans.mv(bec_dev.bs_x, 1.0, bec_dev.bs_y, 1.0, relative=True) # none blocking
# status.wait()
# print(
# f"Moved to position {bec_dev.bs_x.position} with status {status.status}"
# )
# except Exception as e:
# print(f"Error: {e}")
#
# client.shutdown()
#backend wont work unless bec server will work, frontend anywehre with user access
+35
View File
@@ -0,0 +1,35 @@
from enum import Enum
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)
if not self.setpoint_pv.enum_strs:
raise RuntimeError(f"{setpv} is not an ENUM PV")
@property
def position(self) -> str:
return self.readback_pv.get(as_string=True)
def _resolve(self, x: Any) -> MoveResult:
# accept Enum member
if isinstance(x, Enum):
x = x.name
# accept index
if isinstance(x, int):
try:
return MoveResult(target=self.setpoint_pv.enum_strs[x], name=None)
except Exception as e:
raise ValueError(f"Bad enum index {x}") from e
# accept name -> match against enum strings (case-insensitive)
if isinstance(x, str):
for s in self.setpoint_pv.enum_strs:
if s.strip().lower() == x.strip().lower():
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__}")
@@ -1,4 +1,4 @@
from aaredaqlib.beamline import MXBeamline
from aare.common.beamline import MXBeamline
from epics import PV
class ExperimentalHutchShutter:
@@ -2,7 +2,7 @@ import time
from epics import PV, poll
from aaredaqlib.beamline import MXBeamline
from aare.common.beamline import MXBeamline
class FilterTransmission:
@@ -1,12 +1,10 @@
import time
from typing import Callable, cast
from epics import PV, poll
from epics.ca import pend_io
from aaredaqlib.beamline import MXBeamline
from aare.common.beamline import MXBeamline
from aaredaqlib.logger_config import setup_logger
from aare.common.logger_config import setup_logger
logger = setup_logger("aareaDAQ")
@@ -2,11 +2,10 @@ import math
import jfjoch_client
from aaredaqlib.beamline import MXBeamline
from aaredaqlib.diffraction_geometry import DiffractionGeometry
from aaredaqlib.models import SampleShortInfo, DAQStatusModel, FluorescenceSpectrumOutputModel
from aaredaqlib.raster_grid import RasterGridRequest
from aaredaqlib.rotation_scan import RotationScanRequest
from aare.common.beamline import MXBeamline
from aare.common.models import DAQStatusModel, FluorescenceSpectrumOutputModel
from aare.common.raster_grid import RasterGridRequest
from aare.common.rotation_scan import RotationScanRequest
class JFJochWrapper:
@@ -14,6 +13,8 @@ class JFJochWrapper:
match bl:
case MXBeamline.X06DA:
self.__url = "http://sls-gpu-001:8080"
case MXBeamline.X10SA:
self.__url = "http://sls-gpu-002:8080"
case MXBeamline.SIMULATED:
self.__url = "http://localhost:8080"
case _:
@@ -1,44 +1,9 @@
import datetime
import re
import time
from typing import Callable, Union
from typing import Callable, Union, Any
from epics import PV, Motor, poll
def timestamp():
"""
Returns a fixed width string (15 characters) with a timestamp
in the format (24Hour:Minute:Second.Microsecond).
Example: 13:09:43.009508
"""
x = datetime.datetime(1, 1, 1).now()
return "%4d-%02d-%02d %02d:%02d:%02d,%03d" % (
x.year,
x.month,
x.day,
x.hour,
x.minute,
x.second,
x.microsecond / 1000,
)
def itoa(x, base=10):
is_negative = x < 0
if is_negative:
x = -x
digits = []
while x > 0:
x, last_digit = divmod(x, base)
digits.append("0123456789abcdefghijklmnopqrstuvwxyz"[last_digit])
if is_negative:
digits.append("-")
digits.reverse()
return "".join(digits)
def wait_for_movement_to_finish(*motors):
"""
Wait for all {motors} passed in argument to finish movement.
@@ -51,10 +16,6 @@ def wait_for_movement_to_finish(*motors):
Returns: nothing
"""
# timeout = 1.5 * max([abs(motor.get_position(readback=True) -
# motor.get_position()) / motor.slew_speed
# for motor in motors])
#
poll(0.3)
longest = 0.0
for motor in motors:
@@ -77,15 +38,31 @@ class ValueWaitTimeout(Exception):
pass
def pv_wait(pv, value, *, timeout=30.0, polling=0.2, tolerance=None, verbose=False):
if not (isinstance(pv, PV) or isinstance(pv, Motor)):
raise ValueError("wait what!? what pv?!")
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
value/target: any
this value depends on the PV type: str, enum, double, ...
:timeout: float default = 60.0
timeout in seconds
:polling: float default = 0.1
polling interval in seconds
tolerance: float or None
provide a tolerance to accept when comparing values, currently None by default, however certain PVs and motors
have inbuilt tolerances that can be used instead
verbose: bool default = False
NotImplemented
"""
if isinstance(pv, Motor):
def checker(m, target=None, tolerance=None):
if tolerance is None:
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
if target is None:
target = m.drive
@@ -107,14 +84,25 @@ def pv_wait(pv, value, *, timeout=30.0, polling=0.2, tolerance=None, verbose=Fal
def is_epics_type(pv: PV, pv_type: str) -> bool:
"""Check to see if a PV is of a certain type such as double, enum, string, ..."""
if isinstance(pv_type, type):
pv_type = pv_type.__name__
return pv_type == pv.type
def wait_string_condition(pv, target: Union[str, re.Pattern], *, timeout=60.0, polling=0.1):
if not (isinstance(pv, PV) or "string" not in pv.type):
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
:target: str or re.Pattern
:timeout: float default = 60.0
timeout in seconds
:polling: float default = 0.1
polling interval in seconds
"""
if not (isinstance(pv, PV) and "string" not in pv.type):
raise AttributeError("argument 'pv' must be an epics.PV of type string")
if not isinstance(target, re.Pattern):
@@ -131,7 +119,8 @@ def wait_string_condition(pv, target: Union[str, re.Pattern], *, timeout=60.0, p
raise TimeoutError(f"timeout waiting for string {pv.pvname} == {target}; actual value == {pv.char_value}")
def wait_float_condition(pv: PV, value, *, timeout: float = 60.0, **kwargs):
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
@@ -139,30 +128,37 @@ def wait_float_condition(pv: PV, value, *, timeout: float = 60.0, **kwargs):
value: float
the target value
polling: float
how often pv is checked during wait loop
tolerance: float or None
the tolerance to accept when comparing values, if None (default) we
try to figure an appropriate value
timeout: double
timeout: float default = 60.0
a timeout in seconds
return: nothing
raises: TimeoutError if a timeout occurs
"""
if not (isinstance(pv, PV) or "double" not in pv.type):
raise AttributeError("argument 'pv' must be an epics.PV of type double")
try:
value = float(value)
except ValueError:
raise AttributeError("argument 'value' must be a number")
tolerance = kwargs.get("tolerance", pow(10, -(pv.precision - 1))) # type: ignore
polling = kwargs.get("polling", 0.1)
if tolerance is None:
# If pv.precision is missing/None, fall back to a default
precision = getattr(pv, "precision", None)
if precision is None:
tolerance = 1e-6
else:
tolerance = pow(10, -(precision - 1))
tout = time.time() + timeout
while time.time() < tout:
if abs(pv.value - value) < tolerance:
return
@@ -190,13 +186,13 @@ def wait_motor_position(motor: Motor, tester: Callable, *, timeout: float = 50.0
raises: TimeoutError if a timeout occurs
"""
if not isinstance(tester, Callable):
if not callable(tester):
raise RuntimeError("argument 'tester' must be a function")
try:
move_time = abs(motor.drive - motor.readback) / motor.speed
except Exception:
move_time = 1.0 # in case we're dealing with weird motor record
move_time = 1.0 # in case of unusual motor record
tout = move_time + time.time() + timeout
@@ -209,7 +205,7 @@ def wait_motor_position(motor: Motor, tester: Callable, *, timeout: float = 50.0
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, **kwargs):
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
@@ -224,7 +220,7 @@ def wait_enum_condition(pv: PV, value: Union[str, int, re.Pattern], *, timeout:
raises: TimeoutError if a timeout occurs
"""
if not (isinstance(pv, PV) or "enum" != pv.type[-4:].lower()):
if not (isinstance(pv, PV) and pv.type.lower().endswith("enum")):
raise AttributeError("argument 'pv' must be an epics.PV of type enum")
if not (isinstance(value, str) or isinstance(value, int) or isinstance(value, re.Pattern)):
@@ -248,4 +244,12 @@ 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}")
raise TimeoutError(f"timeout waiting for enum {pv.pvname} == {value},"
f"current value is {pv.get(as_string=True)}")
def clean_filename(self, 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
+102
View File
@@ -0,0 +1,102 @@
import time
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')
@speed.setter
def speed(self, v):
"""Sets the motor slew speed"""
self.put('VELO', v)
@property
def position(self):
"""Gets the current motor readback value"""
return self.readback
@property
def value(self):
"""Gets the current motor.drive value"""
return self.drive
@value.setter
def value(self, v):
"""Sets motor.drive to a set value"""
self.drive = v
def stop(self):
"""Stops the motor"""
self.stop_motor()
@property
def moving(self):
"""Returns True if the motor is moving"""
return bool(self.moving_flag)
@property
def units(self):
"""Returns the units of the motor as a string"""
return self.get("EGU", as_string=True)
@property
def limits(self):
"""Returns (low_limit, high_limit)"""
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)
def move_motor(self, val, relative=False, wait=False, timeout=300.0):
"""
Moves the motor to an absolute or relative position.
:param val: Position to move to
:param relative: If True, moves relative to current position
:param wait: If True, waits for completion (synchronous)
:param timeout: Maximum time to wait for completion
"""
return self.move(val, relative=relative, wait=wait, timeout=timeout)
def home(self, direction='forward', wait=False):
"""
Homes the motor.
:param direction: 'forward' or 'reverse'
"""
field = 'HOMF' if direction == 'forward' else 'HOMR'
self.put(field, 1)
if wait:
self.wait_for_stop()
def wait_for_stop(self, timeout=300.0, poll_rate=0.01):
"""
Synchronous wait until the motor stops moving.
"""
start_time = time.time()
while self.moving:
time.sleep(poll_rate)
if time.time() - start_time > timeout:
raise RuntimeError(f"Timeout waiting for motor {self.name} to stop")
async def wait_for_stop_async(self, timeout=300.0, poll_rate=0.01):
"""
Asynchronous wait until the motor stops moving.
"""
import asyncio
start_time = time.time()
while self.moving:
await asyncio.sleep(poll_rate)
if time.time() - start_time > timeout:
raise RuntimeError(f"Timeout waiting for motor {self.name} to stop")
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping, Optional, Union, Callable
from epics import PV, poll
from aare.devices.mx_lib import pv_wait
RawValue = Union[str, float, int]
ResolverValue = Union[
RawValue,
tuple[Callable[..., RawValue], tuple[Any, ...]], # (func, args) pattern you already use
]
@dataclass
class MoveResult:
target: RawValue
name: Optional[str] = None
class SetGetPV:
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)
self.default_timeout = timeout
self.tolerance = tolerance
self._last_target: RawValue | None = None
@property
def value(self) -> Any:
return self.readback_pv.get()
def _resolve(self, x: Any) -> MoveResult:
# Default: treat input as raw value
return MoveResult(target=x, name=None)
def move(self, x: Any, *, wait: bool = False, timeout: float | None = None) -> MoveResult:
res = self._resolve(x)
self._last_target = res.target
self.setpoint_pv.put(res.target)
if wait:
self.wait(timeout=timeout)
return res
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)
class PredefinedPV(SetGetPV):
def __init__(self, name: str, setpv: str, getpv: str, predefs: Mapping[str, ResolverValue], **kwargs):
super().__init__(name, setpv, getpv, **kwargs)
self._predefs = dict(predefs)
@property
def positions(self) -> list[str]:
return list(self._predefs.keys())
def _resolve(self, x: Any) -> MoveResult:
if isinstance(x, str) and x in self._predefs:
v = self._predefs[x]
if isinstance(v, tuple) and callable(v[0]):
v = v[0](*v[1])
return MoveResult(target=v, name=x)
return MoveResult(target=x, name=None)
+234
View File
@@ -0,0 +1,234 @@
from enum import Enum
from time import sleep, time
import requests
from aare.common.beamline import MXBeamline
from aare.common.coordinate import SmargonCoordinate, Coordinate, AerotechCoordinate
from aare.common.exception_handler import SmargonCommunicationError
class SmargonMode(Enum):
UNINITIALIZED = 0
INITIALIZING = 1
READY = 2
ERROR = 99
class Smargon(object):
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 = "http://x06da-smargopolo.psi.ch:3000"
elif bl == MXBeamline.X10SA:
self.__simulated = False
self.__base = "http://x10sa-smargopolo.psi.ch:3000"
elif bl == MXBeamline.SIMULATED:
self.__simulated = True
self.__pos = self.SMARGON_HOME
self.__pos_aero = self.AERO_HOME
else:
raise Exception("unknown beamline")
def gonget(self, thing: str) -> dict:
"""issue a GET for some API component on the smargopolo server
short hand for goniometer get"""
cmd = f"{self.__base}/{thing}"
try:
r = requests.get(cmd, timeout=2.0)
except requests.exceptions.RequestException as e:
raise SmargonCommunicationError(
f"Smargon GET failed for '{thing}'",
endpoint=thing,
base_url=self.__base,
operation="GET",
) from e
if not r.ok:
raise SmargonCommunicationError(
f"Smargon GET returned HTTP {r.status_code} for '{thing}': {r.reason}",
endpoint=thing,
base_url=self.__base,
operation="GET",
status_code=r.status_code,
)
try:
return r.json()
except ValueError as e:
raise SmargonCommunicationError(
f"Smargon GET returned invalid JSON for '{thing}'",
endpoint=thing,
base_url=self.__base,
operation="GET",
status_code=r.status_code,
) from e
def gonput(self, thing: str):
"""issue a PUT command for some API component on the smargopolo server
short hand for goniometer put"""
cmd = f"{self.__base}/{thing}"
try:
r = requests.put(cmd, timeout=2.0)
except requests.exceptions.RequestException as e:
raise SmargonCommunicationError(
f"Smargon PUT failed for '{thing}'",
endpoint=thing,
base_url=self.__base,
operation="PUT",
) from e
if not r.ok:
raise SmargonCommunicationError(
f"Smargon PUT returned HTTP {r.status_code} for '{thing}': {r.reason}",
endpoint=thing,
base_url=self.__base,
operation="PUT",
status_code=r.status_code,
)
def move_home(self, wait=False) -> None:
self.target = self.SMARGON_HOME
if wait:
self.wait()
@property
def mode(self) -> SmargonMode:
return SmargonMode.INITIALIZING
@mode.setter
def mode(self, mode: SmargonMode):
if self.__simulated:
return
self.gonput(f"mode?mode={mode}")
def initialize(self):
self.mode = SmargonMode.UNINITIALIZED
sleep(0.1)
self.mode = SmargonMode.INITIALIZING
def enable_correction(self):
if self.__simulated:
return
self.gonput("corr_type?corr_type=1")
def disable_correction(self):
if self.__simulated:
return
self.gonput("corr_type?corr_type=0")
@property
def readback(self) -> SmargonCoordinate:
if self.__simulated:
return self.__pos
scs = self.gonget("readbackSCS")
return SmargonCoordinate(
sh_mm=Coordinate(x=scs["SHX"], y=scs["SHY"], z=scs["SHZ"]),
phi_deg=scs["PHI"],
chi_deg=scs["CHI"],
)
@property
def readback_aerotech(self) -> AerotechCoordinate:
if self.__simulated:
return self.__pos_aero
acs = self.gonget("readbackAEROTECH")
return AerotechCoordinate(at_mm = Coordinate(x=acs["GMX"], y = acs["GMY"], z = acs["GMZ"]),
omega_deg = acs["GMU"])
@property
def target(self) -> SmargonCoordinate:
if self.__simulated:
return self.__pos
scs = self.gonget("targetSCS") #targetAEROTECH, #targetOMEGA
return SmargonCoordinate(
sh_mm=Coordinate(x=scs["SHX"], y=scs["SHY"], z=scs["SHZ"]),
phi_deg=scs["PHI"],
chi_deg=scs["CHI"],
)
@target.setter
def target(self, coord: SmargonCoordinate):
if self.__simulated:
self.__pos = coord
return
target_string = ""
if coord.sh_mm is not None:
target_string += "&SHX={:.5f}&SHY={:.5f}&SHZ={:.5f}".format(
coord.sh_mm.x, coord.sh_mm.y, coord.sh_mm.z
)
if coord.chi_deg is not None:
target_string += "&CHI={:.5f}".format(coord.chi_deg)
if coord.phi_deg is not None:
target_string += "&PHI={:.5f}".format(coord.phi_deg)
if target_string:
self.gonput(f"targetSCS?{target_string}")
@property
def target_aerotech(self) -> AerotechCoordinate:
if self.__simulated:
return self.__pos_aero
acs = self.gonget("targetAEROTECH") #targetAEROTECH, #targetOMEGA
return AerotechCoordinate(at_mm = Coordinate(x=acs["GMX"], y = acs["GMY"], z = acs["GMZ"]),
omega_deg = acs["GMU"])
@target_aerotech.setter
def target_aerotech(self, coord: AerotechCoordinate):
if self.__simulated:
self.__pos_aero = coord
return
target_string = ""
if coord.at_mm is not None:
target_string += "&GMX={:.5f}&GMY={:.5f}&GMZ={:.5f}".format(
coord.at_mm.x, coord.at_mm.y, coord.at_mm.z
)
if coord.omega_deg is not None:
target_string += "&GMU={:.5f}".format(coord.omega_deg)
if target_string:
self.gonput(f"targetAEROTECH?{target_string}")
def wait(self, timeout=60.0, tol=0.01, poll_time=0.01):
target = self.target
timeout = timeout + time()
while time() < timeout:
if target.eq(self.readback, tol):
break
if time() > timeout:
raise TimeoutError("Timed out waiting for Smargon to reach target")
sleep(poll_time)
def wait_aerotech(self, timeout=60.0, tol=0.01, poll_time=0.01):
target = self.target
timeout = timeout + time()
while time() < timeout:
if self.target_aerotech.eq(self.readback_aerotech, tol):
break
if time() > timeout:
raise TimeoutError("Timed out waiting for Aerotech to reach target")
sleep(poll_time)
if __name__ == "__main__":
smargon = Smargon(MXBeamline.X10SA)
x = smargon.readback_aerotech
print(x)
y = smargon.target_aerotech
smargon.target_aerotech = AerotechCoordinate(at_mm=Coordinate(x=0, y=0, z=0))
print(y)
smargon.wait_aerotech()
print(f"aerotech reached {smargon.readback_aerotech}")
+664
View File
@@ -0,0 +1,664 @@
import json
import random
import re
import time
from typing import List
from urllib.parse import urlparse
import requests
from aare.common.exception_handler import TellCommunicationError
from aare.common.logger_config import setup_logger
from aare.common.models import (
PuckLoadedInfo,
DewarAddress,
SampleDewarAddress,
)
from aareDB import PuckWithTellPosition
from aare.common.beamline import MXBeamline # noqa: F401
from pshell import PShellClient
logger = setup_logger("aareDAQ")
class ManualMountException(Exception):
"""Custom exception for manual mounting"""
pass
class SmartMagnetFaultException(Exception):
"""Custom exception for smart magnet fault"""
pass
class TellMountFailedException(Exception):
"""Custom exception for mount failure"""
pass
class TellCommandWhileBusyException(Exception):
"""Custom exception for trying to move Tell when it is busy"""
pass
class TellConnectionException(Exception):
"""Custom exception for connection problems"""
pass
VALID_DEWAR_POSITIONS = [f"{p}{n}" for n in "12345" for p in "ABCDEFX"]
def is_valid_dewar_position(position):
"""check if argument is a valid dewar position"""
return position in VALID_DEWAR_POSITIONS
POSITION_PARK = "pPark"
POSITION_COLD = "pCold"
POSITION_AUX = "pAux"
POSITION_DEWAR = "pDewar"
POSITION_HOME = "pHome"
POSITION_HEATER = "pHeatB"
#Nov 26 13:36:00 mx-x06da-queue-01.psi.ch AareDAQ[2944444]: 2025-11-26 13:36:00,388 - aareDAQ - ERROR - Error getting status: ('Connection aborted.', ConnectionResetError(104, 'Connection reset by peer'))
class TellClient:
"""High-level Tell robot API using PShellClient"""
def __init__(self, bl: MXBeamline):
self.__url = None
beamline = bl.value.lower()
self.__beamline = bl
if bl == MXBeamline.X06DA:
self.__url = f"http://{beamline}-tell.psi.ch:22222"
elif bl == MXBeamline.X10SA:
self.__url = f"http://PC17488:22222"
elif bl == MXBeamline.X06SA:
self.__url = f""
raise NotImplemented(f"TellClient not implemente for {beamline}")
elif bl == MXBeamline.SIMULATED:
raise NotImplemented(f"Use SimClient, generate tell client using"
f"make_tell_client(beamline)")
else:
raise ValueError(f"Unknown beamline {beamline}")
print(f"Connecting TELL p-shell service at {self.__url} ...", end="")
hostname = urlparse(self.__url).hostname
try:
requests.get(f"{self.__url}/history/0", timeout=1.0)
except requests.exceptions.RequestException as e:
print(f"...connection to {hostname} failed")
raise TellCommunicationError(
f"TELL connection failed ({hostname})",
base_url=self.__url,
endpoint="history/0",
operation="GET",
) from e
except requests.ReadTimeout as e:
print(f"...PShell service {hostname} is down")
raise TellCommunicationError(
f"TELL connection timedout ({hostname})",
base_url=self.__url,
endpoint="history/0",
operation="GET",
) from e
self.pshell = PShellClient(self.__url)
self._aborted = False
self.state = self.get_state()
self.debug = False
self._last_cmd_id = -1
@property
def url(self):
"""returns the configured base url for the Tell robot"""
return self.__url
def get_state(self):
"""returns the current state of the robot"""
self.state = self.pshell.get_state()
return self.state
def get_result(self, command_id=-1):
"""returns the result of the last command issued to the robot"""
return self.pshell.get_result(command_id)
def wait_ready(self, timeout: float = 360.0):
"""waits until the robot is ready to accept commands returns None if simulation
and raises an exception if the robot is not ready"""
self.pshell.wait_state("Ready", timeout=timeout)
def wait_not_busy(self, timeout: float = 360.0):
"""waits until the robot is not busy and returns None if simulation
and raises an exception if the robot is busy"""
self.pshell.wait_state_not("Busy", timeout=timeout)
state = self.get_state()
if state != "Ready":
if state == "Initializing":
raise TellConnectionException("Tell reconnecting")
elif state == "Closing":
raise TellConnectionException("Tell is disconnecting")
raise Exception("Invalid state: " + str(state))
def set_in_mount_position(self, value):
"""tells the robot that the beamlien is safe and to set the in mount position flag allowing mounting
:param value """
self.pshell.eval("in_mount_position = " + str(value) + "&")
def is_in_mount_position(self) -> bool:
"""checks to see if the robot is in the mount position and returns a boolean"""
return self.pshell.eval("in_mount_position&").lower() == "true"
def set_samples_info(self, info: List[PuckWithTellPosition]):
"""sets the samples in the robot dewar based on the given list of PuckWithTellPosition objects
and runs set_sample_info in the background"""
j = []
for x in info:
j.append(
{
"userName": x.pgroup,
"dewarName": x.dewar_name or "",
"puckName": x.puck_name,
"puckType": "Unipuck", # could use x.puck_type
"puckAddress": x.tell_position or "",
"puckBarcode": x.puck_name,
"sampleBarcode": "",
"sampleMountCount": 0,
"sampleName": "",
"samplePosition": 1,
"sampleStatus": "",
}
)
self.pshell.run("data/set_samples_info", pars=[json.dumps(j)], background=True)
# self.pshell.eval("set_samples_info(" + json.dumps(info) + ")&")
def start_cmd(self, cmd, *argv):
"""starts a command on the robot and returns the command id"""
cmd = cmd + "("
for a in argv:
cmd = cmd + (("'" + a + "'") if type(a) is str else str(a)) + ", "
cmd = cmd + ")"
ret = self.pshell.start_eval(cmd)
self.get_state()
return ret
def check_command_ok(self, timeout: float = 360.0, msg: str = ""):
"""checks to see if the last command issued to the robot was completed and returns the result
Returns an exception if the command result doesnt return completed or removed"""
self.wait_not_busy(timeout)
result = self.get_result(self._last_cmd_id)
logger.debug(f"{msg} {result}")
status = result["status"]
if "completed" != status: #FIXME this is very limiting and depends on tell reporting statuses
if "removed" != status:
raise TellMountFailedException(f"{msg} {result}")
else:
return f"{msg} {result}"
def estimate_mounting_time(self, segment) -> int:
"""Adds additional time if cooling/drying is expected based on requested segment,
current sample segment and gripper position.
:param segment: any - however valid segment ABCDEFX """
try:
current_mounted = self.get_mounted_sample()
gripper_in_cold = self.is_in_cold()
if current_mounted is None:
unmount_needs_drying = 0 # might not have anything
unmount_needs_cooling = 0
else:
segment_in_cold = current_mounted.puck.segment in "ABCDEF"
unmount_needs_drying = int(gripper_in_cold and not segment_in_cold)
unmount_needs_cooling = int(not gripper_in_cold and segment_in_cold)
mount_needs_cooling = int(segment in "ABCDEF" and not gripper_in_cold)
mount_needs_drying = int(segment not in "ABCDEF" and gripper_in_cold)
needs_cooling = mount_needs_cooling + unmount_needs_cooling
needs_drying = mount_needs_drying + unmount_needs_drying
return needs_cooling * 30 + needs_drying * 120
except:
return 0
def mount(
self,
address: SampleDewarAddress,
force: bool = False, # kept for future
read_dm: bool = False, # read data matrix
auto_unmount: bool = False, # single command, if False it will raise exception
wait: bool = False, # blocking operation
timeout: float = 600.0,
):
"""send api request to mount sample from dewer after validating dewer address returns None or repsonse.
If the robot is busy, mount will raise an exception.
:param address: SampleDewarAddress
:param force: bool
:param read_dm: bool
:param auto_unmount: bool
:param wait: bool
:param timeout: float
"""
SampleDewarAddress.model_validate(address)
segment = address.puck.segment
puck = address.puck.pos
sample = address.pin
if self.is_busy():
raise TellCommandWhileBusyException("mount received while robot is busy")
logger.info(f"loading sample {sample} from segment {segment} - {puck}")
self._last_cmd_id = self.start_cmd(
"mount", segment, puck, sample, force, read_dm, auto_unmount
)
wait_timeout = timeout + self.estimate_mounting_time(segment)
logger.info("waiting for mount to complete")
if wait and segment in "ABCDEF":
event, value = self.pshell.wait_events({"state": None, "Motion Task": "dry",
"Gripper detection" : None,
"Motion Sync": "Robot Clear after mount"}, timeout=wait_timeout)
if event is None or event == "state":
logger.info(f"event: {event} occurred with value: {value}, checking command completed okay")
self.check_command_ok(
timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: "
)
return value
elif event == "Gripper detection" and value == "No Pin in Gripper":
logger.info(f"gripper detection: {event} occurred with value: {value}")
return value
elif event == "Gripper detection" and value == "Pin still in Gripper":
logger.info(f"gripper detection: {event} occurred with value: {value}")
return value
elif event == "Gripper detection" and value == "Pin is lost":
logger.info(f"gripper detection: {event} occurred with value: {value}")
return value
elif event == "Motion Task" and value == "dry":
logger.info(f"event: {event} occurred with value: {value}")
logger.info(" Drying occurring, releasing interface to user")
return value
elif event == "Motion Sync" and value == "Robot Clear after mount":
logger.info(f"event: {event} occurred with value: {value}")
logger.info(" Mounting complete, releasing interface to user")
return value
else:
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}: "
)
elif wait and segment == "X":
logger.info("Loading an auxiliary puck")
self.check_command_ok(
timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: "
)
logger.info("post waiting")
return None
def unmount(self, force=False, wait=False, timeout=360.0):
"""send api request to unmount sample from dewer returns None or repsonse.
:param force: bool Force has a meaning, will unmount even if smart magnet is not detecting sample
:param wait: bool If true will wait until unmount is completed
:timeout: float"""
if self.is_busy():
raise TellCommandWhileBusyException("mount received while robot is busy")
self._last_cmd_id = self.start_cmd("unmount", None, None, None, force)
if wait:
self.check_command_ok(timeout=timeout, msg="Unmount message: ")
return self._last_cmd_id
def dry(self, heat_time=None, speed=None, wait_cold=None, wait=False):
"""send api request to dry tell gripper.
:param: heat_time float if None Tell will use default for drying time
:param: speed float if None Tell will use default for drying speed
:param: wait_cold bool if -1 to go to park after dry. if None Tell will use default time to wait_cold.
:param wait: bool If true will wait until drying is completed
"""
self.pshell.wait_state("Ready", timeout=30.0)
self._last_cmd_id = self.start_cmd("dry", heat_time, speed, wait_cold)
if wait:
self.check_command_ok(timeout=360.0, msg=f"Dry failed")
def move_park(self, wait=False):
"""send api request to move robot to park position"""
self._last_cmd_id = self.start_cmd("move_park")
if wait:
self.check_command_ok(timeout=360.0, msg=f"Move to park failed")
def move_cold(self, reset_timestamp=False, wait=False):
"""send api request to move robot to cold position"""
self._last_cmd_id = self.start_cmd("move_cold", reset_timestamp)
if wait:
self.check_command_ok(timeout=360.0, msg=f"Move to cold failed")
def abort_cmd(self):
"""sends an abort pshell requesst and a robot stop task command"""
self.pshell.abort()
self.pshell.eval("robot.stop_task()&")
def set_setting(self, key: str, value: str):
"""wrapper for pshell eval set_setting command
:param key str, name of a setting in tell
:param value str, the new value of the setting as a string"""
self.pshell.eval(f"set_setting('{key}', '{value}')&")
def get_setting(self, key: str) -> str:
"""wrapper for pshell eval get_setting command, returns the current value for key as a string
:param key str, name of a setting in tell"""
return self.pshell.eval(f"get_setting('{key}')&")
def get_mounted_sample(self) -> SampleDewarAddress | None:
"""get the current mounted sample and return a SampleDewarAddress object or None if no sample is mounted"""
ret = self.get_setting('mounted_sample_position').strip()
if not ret or len(ret) == 0:
return None
match = re.match(r"([A-Z])(\d)(\d{1,2})", ret)
if match:
segment, puck, sample = match.groups()
dewar_location = DewarAddress(segment=segment, pos=int(puck))
return SampleDewarAddress(puck=dewar_location, pin=int(sample))
else:
logger.warning(f"Failed to decode mounted sample position: {ret}")
return None
def get_system_check(self):
"""returns the current system check status"""
return self.pshell.eval("system_check_msg()&")
def get_robot_state(self):
"""returns the current robot state"""
return self.pshell.eval("robot.state&")
def get_robot_status(self):
"""returns the current robot status"""
status = self.pshell.eval("robot.take()&")
return eval(status) # FIXME ALL functions must return a valid JSON object
def get_detected_pucks(self) -> List[PuckLoadedInfo]:
"""returns a list of detected pucks as PuckLoadedInfo objects"""
j = json.loads(self.pshell.eval("get_pucks_info()&"))
output = []
for i in j:
if i["puckState"] == "Present":
puck_address = i["puckAddress"]
if len(puck_address) == 2:
output.append(
PuckLoadedInfo(
puck_name=i["puckBarcode"],
location=DewarAddress(
segment=puck_address[0], pos=int(puck_address[1])
),
),
)
return output
def get_pin_offset(self):
"""get the pin offset for the smart magnet, returns offset as a float"""
try:
offset = float(self.pshell.eval("get_pin_offset()&"))
except Exception:
offset = 0.0
return offset
def get_current(self):
"""get the current drawn by the smart magnet, returns current as a float in mA"""
current = self.pshell.eval("smart_magnet.get_current_rb()&")
return float(current)
def set_current(self, current: float) -> float:
"""set the current drawn by the smart magnet, returns current as a float in mA"""
self.pshell.eval("smart_magnet.set_current({:.1f})&".format(current))
current = self.pshell.eval("smart_magnet.get_current_rb()&")
return float(current)
def is_powered(self):
"""returns True if the robot is powered on"""
return self.get_robot_status()["powered"]
def check_enable_motion(self):
"""check if the robot is powered on and enable motion if not"""
if not self.is_powered():
self.pshell.eval("enable_motion()&")
def is_in_cold(self):
"""Compare current robot position to the set cold position. Returns True if in cold position, False otherwise."""
return self.is_position(POSITION_COLD)
def is_position(self, position: str) -> bool:
"""Compare current robot position to a given position. Returns True if in position, False otherwise."""
return position == self.get_robot_status()["pos"]
def is_ready(self):
"""returns True if the robot is ready to receive commands"""
return "ready" == self.get_state().lower()
def is_busy(self):
"""returns True if the robot is busy"""
return "busy" == self.get_state().lower()
def check_smart_magnet_mounted(self, timeout: float = 10.0, idle_time: float = 1.0, interval: float = 0.1):
"""Reads smart_magent state and tries to infer if a sample is present
Handles: PAUSED, Fault, Busy and Ready states.
Raises a ManualMountException is the amgnet indicates a sample is present but get_mounted_sample is None.
Raises a SmartMagnetFaultException if the magnet detects no sample but the robot thinks a sample is mounted"""
#TODO tidy up
initial_state = self.pshell.eval("smart_magnet.state&")
logger.debug(f"checking smart magnet_initial state: {initial_state}")
if initial_state == "Paused":
self.pshell.eval("smart_magnet.set_supress(False)&")
self.pshell.eval("smart_magnet.set_resting_current()&")
elif initial_state == "Fault":
logger.error(f"tell smart magnet is in unknown state {initial_state}")
raise SmartMagnetFaultException
state = self.pshell.eval("smart_magnet.state&")
try:
if state == "Busy":
logger.debug('state busy')
self.pshell.eval("smart_magnet.set_supress(True)&")
self.pshell.eval("smart_magnet.state&")
sample_present = True
if self.get_mounted_sample() is None:
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')
sample_present = False
if self.get_mounted_sample():
logger.error("Check mount: No sample detected, but robot thinks is mounted")
raise SmartMagnetFaultException
return False
elif state == "Paused":
logger.debug("Smart magnet detection is paused")
return None
else:
self.pshell.eval("smart_magnet.set_supress(True)&")
logger.error(f"Tell smart magnet is in unknown state {state}")
raise SmartMagnetFaultException
except Exception as e:
logger.error(f"check_smart_magnet_mounted failed: {e}")
raise e
class SimTellClient:
"""
Simulation-only Tell client.
Keeps behavior deterministic-ish and stateful without needing PShellClient.
Implement more methods as your callers need them.
"""
def __init__(self):
self._state = "Ready"
self._last_cmd_id = 1000
self._mounted_sample: str = ""
self._simulated_samples_info = {}
self._simulated_detected_pucks = []
self._simulated_current = 30.0
self._simulated_suppress = True
self._simulated_offset = 0.0
@property
def url(self):
return None
def _next_cmd_id(self) -> int:
self._last_cmd_id += 1
return self._last_cmd_id
def get_state(self) -> str:
return self._state
def is_ready(self) -> bool:
return self._state.lower() == "ready"
def is_busy(self) -> bool:
return self._state.lower() == "busy"
def wait_ready(self, timeout: float = 360.0):
# Keep it simple: flip to Ready quickly.
time.sleep(0.05)
self._state = "Ready"
def mount(
self,
address: SampleDewarAddress,
force: bool = False,
read_dm: bool = False,
auto_unmount: bool = False,
wait: bool = False,
timeout: float = 600.0,
):
SampleDewarAddress.model_validate(address)
if self.is_busy():
raise TellCommandWhileBusyException("mount received while robot is busy")
cmd_id = self._next_cmd_id()
self._state = "Busy"
segment = address.puck.segment
puck = address.puck.pos
sample = address.pin
self._mounted_sample = f"{segment}{puck}{sample}"
if wait:
self.wait_ready(timeout=timeout)
else:
# quickly become ready anyway, but asynchronously-ish
time.sleep(0.01)
self._state = "Ready"
return cmd_id
def unmount(self, force: bool = False, wait: bool = False, timeout: float = 360.0):
if self.is_busy():
raise TellCommandWhileBusyException("unmount received while robot is busy")
cmd_id = self._next_cmd_id()
self._state = "Busy"
self._mounted_sample = ""
if wait:
self.wait_ready(timeout=timeout)
else:
time.sleep(0.01)
self._state = "Ready"
return cmd_id
def get_mounted_sample(self) -> SampleDewarAddress | None:
ret = self._mounted_sample
if not ret:
return None
match = re.match(r"([A-Z])(\d)(\d{1,2})", ret)
if not match:
return None
segment, puck, sample = match.groups()
return SampleDewarAddress(puck=DewarAddress(segment=segment, pos=int(puck)), pin=int(sample))
class TellClientProxy:
"""
Lazy-connecting Tell client proxy that retries periodically.
- Server can start even if TELL is down.
- First use triggers connect; failures raise TellCommunicationError.
"""
def __init__(self, bl: MXBeamline, *, retry_interval_s: float = 2.0):
self._bl = bl
self._client: TellClient | None = None
self._retry_interval_s = float(retry_interval_s)
self._last_attempt_ts = 0.0
self._last_error: Exception | None = None
def _get_client(self) -> TellClient:
if self._client is not None:
return self._client
now = time.monotonic()
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
try:
self._client = TellClient(self._bl)
self._last_error = None
return self._client
except TellCommunicationError as e:
self._last_error = e
raise
except Exception as e:
wrapped = TellCommunicationError(
"TELL connection failed",
operation="CONNECT",
)
self._last_error = wrapped
raise wrapped from e
@property
def url(self):
return self._get_client().url
# Delegate methods used by DAQ; add more as needed
def get_mounted_sample(self) -> SampleDewarAddress | None:
return self._get_client().get_mounted_sample()
def get_state(self):
return self._get_client().get_state()
def wait_not_busy(self, timeout: float = 360.0):
return self._get_client().wait_not_busy(timeout=timeout)
def check_enable_motion(self):
return self._get_client().check_enable_motion()
def set_in_mount_position(self, value):
return self._get_client().set_in_mount_position(value)
def mount(self, *args, **kwargs):
return self._get_client().mount(*args, **kwargs)
def unmount(self, *args, **kwargs):
return self._get_client().unmount(*args, **kwargs)
def abort_cmd(self):
return self._get_client().abort_cmd()
def make_tell_client(bl: MXBeamline) -> TellClient | SimTellClient | TellClientProxy:
if bl == MXBeamline.SIMULATED:
return SimTellClient()
return TellClientProxy(bl, retry_interval_s=2.0)
+65
View File
@@ -0,0 +1,65 @@
import os
import jwt
import requests
from aare.common.models import TokenData
from aare.common.logger_config import setup_logger
logger = setup_logger('aareGUI')
def auth(base_url: str | None) -> str:
curr_user = os.getlogin()
if base_url is None:
token_data = TokenData(sub=curr_user,
staff=True,
session=15,
pgroups=["p16371", "p22233"])
return jwt.encode(token_data.model_dump(), "ABC123")
url = f"{base_url}/token"
try:
response = requests.post(
url,
data={
"username": curr_user,
"password": ""
},
headers={
"Content-Type": "application/x-www-form-urlencoded"
},
timeout=(2.0, 5.0), # (connect timeout, read timeout)
)
except requests.RequestException as e:
logger.error(f"Authentication request failed (network): {e}")
raise RuntimeError(
"Cannot reach AareDAQ server (network error). "
"Please check the server is running and your connection."
) from e
if response.status_code != 200:
# Avoid dumping full HTML/tracebacks into the GUI; keep it short and actionable
logger.error(f"Authentication request failed: HTTP {response.status_code}. Body: {response.text[:500]}")
raise RuntimeError(
f"Authentication failed (HTTP {response.status_code}). "
"The server may be starting up or unavailable."
)
try:
response_json = response.json()
except ValueError as e:
logger.error(f"Authentication response was not JSON. Body: {response.text[:500]}")
raise RuntimeError(
"Authentication failed (invalid server response). "
"The server may be starting up or misconfigured."
) from e
token = response_json.get("access_token")
if not token or not isinstance(token, str):
logger.error(f"Authentication response missing access_token. Keys: {list(response_json.keys())}")
raise RuntimeError(
"Authentication failed (missing token in server response). "
"The server may be starting up."
)
return token
+670
View File
@@ -0,0 +1,670 @@
import time
from typing import Callable, Iterable
import cv2
import numpy as np
from aare.common.autofocus_tools import focus_measure_edges
from aare.common.beamline import mx_beamline
from aare.common.coordinate import Coordinate, SmargonCoordinate
from aare.common.models import AutofocusSettings
from aare.daq.config import BeamlineConfig
from aare.daq.daq import AareDAQ
from aare.daq.devices import BeamlineDevices
def make_circular_mask(shape_hw: tuple[int, int], center_x: float, center_y: float, radius: float) -> np.ndarray:
h, w = int(shape_hw[0]), int(shape_hw[1])
y, x = np.ogrid[:h, :w]
return (x - float(center_x)) ** 2 + (y - float(center_y)) ** 2 <= float(radius) ** 2
def _parabola_vertex(x1, y1, x2, y2, x3, y3) -> float | None:
# Fit parabola through 3 points; return vertex x if it's a maximum.
denom = (x1 - x2) * (x1 - x3) * (x2 - x3)
if abs(denom) < 1e-15:
return None
a = (x3 * (y2 - y1) + x2 * (y1 - y3) + x1 * (y3 - y2)) / denom
b = (x3**2 * (y1 - y2) + x2**2 * (y3 - y1) + x1**2 * (y2 - y3)) / denom
if a >= 0:
return None
return float(-b / (2 * a))
class AutofocusController:
"""
Fast autofocus: bracket -> ternary -> optional parabola.
You inject:
- get_gray_image(): np.ndarray (2D)
- get_frame_id(): int (UniqueId) OR None
- move_to(z): move stage to requested z (units are up to you)
- wait_for_stop(): block until motion ends
The key speed/robustness trick is waiting for a *new frame id* after motion.
"""
def __init__(
self,
*,
get_gray_image,
focus_measure,
move_to,
wait_for_stop,
get_frame_id=None,
fps: float = 25.0,
):
self.get_gray_image = get_gray_image
self.focus_measure = focus_measure
self.move_to = move_to
self.wait_for_stop = wait_for_stop
self.get_frame_id = get_frame_id
self.fps = float(fps)
self._uid_stuck_count = 0
self._uid_stuck_disable_after = 3
def _wait_new_frames(self, frames: int = 1, timeout_s: float = 0.12) -> bool:
"""
Wait for new frames by UniqueId.
If uid appears stuck (common in standalone tests if acquisition isn't running),
quickly fall back to a short sleep so autofocus stays fast.
"""
if self.get_frame_id is None or self._uid_stuck_count >= self._uid_stuck_disable_after:
time.sleep(max(0.0, float(frames)) / max(1e-6, self.fps))
return True
start = int(self.get_frame_id())
print(f"Waiting for {frames} frames (uid={start})...")
target = start + int(frames)
deadline = time.perf_counter() + float(timeout_s)
while time.perf_counter() < deadline:
if int(self.get_frame_id()) >= target:
self._uid_stuck_count = 0
print(f"Acquired {frames} frames (uid={target}, start={start})")
return True
time.sleep(0.001)
# uid didn't advance in time -> count as "stuck" and fall back
print(f"UID stuck for {timeout_s} s, falling back to sleep...")
self._uid_stuck_count += 1
time.sleep(1.0 / max(1e-6, self.fps))
return False
def _score_at(self, z, mask: np.ndarray | None, robust_frames: int) -> float:
st = time.perf_counter()
self.move_to(z)
print(f"move_to command to z={z:.2f} (t={time.perf_counter() - st:.5f} s)")
st = time.perf_counter()
self.wait_for_stop()
print(f"wait_for_stop command (t={time.perf_counter() - st:.5f} s)")
# Ensure next image is not a stale buffer
print("Waiting for new frame...")
st = time.perf_counter()
self._wait_new_frames(frames=1, timeout_s=0.4)
print(f"Acquired new frame (t={time.perf_counter() - st:.5f} s)")
st = time.perf_counter()
if robust_frames <= 1:
gray = self.get_gray_image()
print(f"got grey image (t={time.perf_counter() - st:.5f} s)")
return float(self.focus_measure(gray, mask))
vals: list[float] = []
for _ in range(int(robust_frames)):
gray = self.get_gray_image()
vals.append(float(self.focus_measure(gray, mask)))
self._wait_new_frames(frames=1, timeout_s=0.4)
print(f'Got values after {time.perf_counter() - st:.5f} s: {vals}')
return float(np.median(np.asarray(vals, dtype=np.float64)))
def run_once(
self,
*,
z0: float,
z_range: float,
mask: np.ndarray | None = None,
robust_frames: int = 1,
ternary_iters: int = 4,
do_parabola: bool = True,
edge_stop: bool = True,
flat_rel_tol: float = 0.03,
) -> tuple[float, float]:
"""
Returns (best_z, best_focus).
edge_stop:
If True and the best bracket point is at ±0.5*z_range, stop early.
(Means the peak is likely outside the search window.)
flat_rel_tol:
If (max-min)/max is below this, treat focus curve as flat and stop early.
"""
R = float(z_range)
# 1) 5-point bracket
zs = np.array(
[z0 - 0.5 * R, z0 - 0.25 * R, z0, z0 + 0.25 * R, z0 + 0.5 * R],
dtype=np.float64,
)
fs = np.array([self._score_at(float(z), mask, robust_frames) for z in zs], dtype=np.float64)
f0 = float(fs[2]) # z0
f_max = float(fs.max())
if f0 > 0 and (f_max / f0) < 1.05: # <5% improvement available
return float(zs[2]), f0
best_i = int(np.argmax(fs))
z_best = float(zs[best_i])
f_best = float(fs[best_i])
# Early exit if the curve is basically flat (no meaningful improvement)
f_min = float(fs.min())
if f_max > 0 and (f_max - f_min) / f_max < float(flat_rel_tol):
return z_best, f_best
# Early exit if best is at range edge: bracket does not contain a maximum
if edge_stop and (best_i == 0 or best_i == len(zs) - 1):
return z_best, f_best
# Local bracket for ternary search
iL = max(0, best_i - 1)
iR = min(len(zs) - 1, best_i + 1)
zL, zR = float(zs[iL]), float(zs[iR])
sampled: dict[float, float] = {float(zs[i]): float(fs[i]) for i in range(len(zs))}
if zL == zR:
return z_best, f_best
# 2) ternary search in local bracket (assumes unimodal-ish)
for _ in range(int(ternary_iters)):
a, b = (zL, zR) if zL < zR else (zR, zL)
z1 = a + (b - a) / 3.0
z2 = b - (b - a) / 3.0
if z1 not in sampled:
sampled[z1] = self._score_at(float(z1), mask, robust_frames)
if z2 not in sampled:
sampled[z2] = self._score_at(float(z2), mask, robust_frames)
if sampled[z1] < sampled[z2]:
zL = z1
else:
zR = z2
# 3) optional 3-point parabola around current best sample
if do_parabola and len(sampled) >= 3:
items = sorted(sampled.items(), key=lambda t: t[0])
zz = np.array([p[0] for p in items], dtype=np.float64)
ff = np.array([p[1] for p in items], dtype=np.float64)
k = int(np.argmax(ff))
if 0 < k < len(zz) - 1:
zv = _parabola_vertex(
float(zz[k - 1]), float(ff[k - 1]),
float(zz[k]), float(ff[k]),
float(zz[k + 1]), float(ff[k + 1]),
)
if zv is not None and float(zz[k - 1]) <= zv <= float(zz[k + 1]):
if zv not in sampled:
sampled[zv] = self._score_at(float(zv), mask, robust_frames)
z_best, f_best = max(sampled.items(), key=lambda t: t[1])
return float(z_best), float(f_best)
def __auto_focus(settings: AutofocusSettings) -> float:
"""
Fast autofocus on Smargon Z:
- bracket (5 points)
- ternary search (few iters)
- optional parabola refine
Returns:
Best Z offset in mm (beamline Z delta) relative to the starting position.
"""
geom = daq.sample_geometry
start_smargon = devs.smargon_pos
# ROI center: use provided, else use beam location (beam mark)
center_x = float(settings.center_x_pxl) if settings.center_x_pxl is not None else float(geom.beam_location_pxl.x)
center_y = float(settings.center_y_pxl) if settings.center_y_pxl is not None else float(geom.beam_location_pxl.y)
radius_pxl = float(settings.radius_pxl)
z_range_mm = float(settings.z_range_um) / 1000.0
z_steps = int(settings.z_steps)
# Build mask once (needs image shape)
first = daq.camera_image_gray
if first is None:
raise RuntimeError("Autofocus: no camera image available.")
if first.ndim != 2:
raise RuntimeError("Autofocus: expected grayscale image (2D).")
#mask = make_circular_mask(first.shape[:2], center_x=center_x, center_y=center_y, radius=radius_pxl)
height, width = first.shape
y, x = np.ogrid[:height, :width]
mask = (x - center_x) ** 2 + (y - center_y) ** 2 <= radius_pxl ** 2
def move_to_delta_z_mm(dz_mm: float) -> None:
# Apply relative motion in *beamline Z* via the geometry transform
sh_new = start_smargon.sh_mm + geom.smargon_nudge(Coordinate(z=float(dz_mm)))
target = SmargonCoordinate(
sh_mm=sh_new,
phi_deg=start_smargon.phi_deg,
chi_deg=start_smargon.chi_deg,
)
devs.smargon_pos = target
def wait_for_stop() -> None:
devs.smargon_wait(timeout=30)
def get_gray() -> np.ndarray:
img = daq.camera_image_gray
if img is None:
raise RuntimeError("Autofocus: failed to acquire image.")
return img
ctrl = StepwiseAutofocus(
get_gray_image=get_gray,
focus_measure=focus_measure_edges,
move_to=move_to_delta_z_mm,
wait_for_stop=wait_for_stop,
get_frame_id=devs.samcam_frame_id,
fps=25.0,
)
# Robustness vs speed:
# - 1 is fastest
# - 2 is more stable (median of 2 frames) and often still < 1 s total
robust_frames = 1
best_dz, best_f, zs, fs = ctrl.run(z0=0.0, z_range=z_range_mm, z_steps=z_steps, mask=mask,
refine=False, include_baseline=False)
# Move to the best position (controller ends at last probed z; ensure final is best)
move_to_delta_z_mm(best_dz)
wait_for_stop()
print(
f"Autofocus complete: best_dz={best_dz * 1000.0:.1f} um, focus={best_f:.2f}, "
f"roi_center=({center_x:.1f},{center_y:.1f}), r={radius_pxl:.1f}px"
)
return float(best_dz)
# def auto_focus(self, settings: AutofocusSettings) -> float:
# """
# Public autofocus method. Only allowed in SampleAlignment state.
# Returns best Z offset in mm (beamline Z delta) relative to start.
# """
# self.__cfg.set_busy(BeamlineStateEnum.SampleAlignment)
# try:
# best_dz_mm = self.__auto_focus(settings)
# self.__cfg.state_busy = False
# return best_dz_mm
# except Exception as e:
# logger.error(f"Autofocus failed: {e}")
# self.__cfg.state_busy = False
# raise
class StepwiseAutofocus:
def __init__(self, *, get_gray_image, focus_measure, move_to, wait_for_stop, get_frame_id=None, fps=25.0):
self.get_gray_image = get_gray_image
self.focus_measure = focus_measure
self.move_to = move_to
self.wait_for_stop = wait_for_stop
self.get_frame_id = get_frame_id
self.fps = float(fps)
def _wait_new_frame(self, timeout_s: float = 0.25) -> None:
if self.get_frame_id is None:
time.sleep(1.0 / max(1e-6, self.fps))
return
start = int(self.get_frame_id())
deadline = time.perf_counter() + float(timeout_s)
while time.perf_counter() < deadline:
if int(self.get_frame_id()) > start:
return
time.sleep(0.001)
# fallback: don't hang
time.sleep(1.0 / max(1e-6, self.fps))
def score_at(self, z: float, mask=None) -> float:
self.move_to(float(z))
self.wait_for_stop()
self._wait_new_frame(timeout_s=0.25)
gray = self.get_gray_image()
return float(self.focus_measure(gray, mask))
def run(
self,
*,
z0: float,
z_range: float,
z_steps: int,
mask=None,
refine: bool = False,
include_baseline: bool = False,
) -> tuple[float, float, np.ndarray, np.ndarray]:
"""
Returns:
(best_z, best_focus, z_positions, focus_values)
If include_baseline=False and refine=False, this will evaluate focus exactly `z_steps` times.
"""
z_steps = int(z_steps)
if z_steps < 3:
raise ValueError("z_steps must be >= 3 for a meaningful scan.")
if include_baseline:
_ = self.score_at(float(z0), mask=mask)
zs = np.linspace(z0 - 0.5 * float(z_range), z0 + 0.5 * float(z_range), z_steps, dtype=np.float64)
fs = np.empty_like(zs)
for i, z in enumerate(zs):
fs[i] = self.score_at(float(z), mask=mask)
best_i = int(np.argmax(fs))
best_z = float(zs[best_i])
best_f = float(fs[best_i])
if refine and 0 < best_i < (len(zs) - 1):
dz = float(zs[best_i + 1] - zs[best_i])
z_candidates = np.array([best_z - dz, best_z, best_z + dz], dtype=np.float64)
f_candidates = np.array([self.score_at(float(zc), mask=mask) for zc in z_candidates], dtype=np.float64)
j = int(np.argmax(f_candidates))
best_z = float(z_candidates[j])
best_f = float(f_candidates[j])
return best_z, best_f, zs, fs
def __auto_focus_with_aerotech(settings: AutofocusSettings) -> float:
"""
1) Fast focus scan on Aerotech GMZ (true focus axis)
2) Return GMZ to home position
3) Apply one Smargon move to preserve the focus (using a local Jacobian estimate)
Returns:
Smargon delta (in the same "beamline z command" units you use in geom.smargon_nudge(Coordinate(z=...))).
"""
geom = daq.sample_geometry
start_smargon = devs.smargon_pos
center_x = float(geom.beam_location_pxl.x)
center_y = float(geom.beam_location_pxl.y)
radius_pxl = float(settings.radius_pxl)
z_range_mm = float(settings.z_range_um) / 1000.0
z_steps = int(settings.z_steps)
def get_gray() -> np.ndarray:
"""
Match GUI pipeline:
- if Bayer: debayer -> RGB
- flip horizontally
- convert to gray (uint8)
"""
img = daq.camera_image # <-- NOTE: use raw, not camera_image_gray
if img is None:
raise RuntimeError("Autofocus: failed to acquire image.")
# If already grayscale
if img.ndim == 2:
bayer = img.astype(np.uint8, copy=False)
rgb = cv2.cvtColor(bayer, cv2.COLOR_BAYER_GB2RGB)
rgb = rgb[:, ::-1, :].copy()
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
return gray
# If RGB-like
if img.ndim == 3 and img.shape[2] >= 3:
rgb = img[:, :, :3]
rgb = rgb[:, ::-1, :].copy()
if rgb.dtype != np.uint8:
rgb = np.clip(rgb, 0, 255).astype(np.uint8)
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
return gray
raise RuntimeError(f"Autofocus: unexpected image shape {img.shape}")
first = get_gray()
print(first.shape[:2])
print(center_x, center_y, radius_pxl)
print((first.shape[1]-1) - center_x)
if first is None or first.ndim != 2:
raise RuntimeError("Autofocus: no grayscale image available.")
mask = make_circular_mask(first.shape[:2], center_x=center_x, center_y=center_y, radius=radius_pxl)
def score_focus() -> float:
gray = get_gray()
# Ensure we're comparing apples-to-apples in logs
g = gray
if g.dtype != np.uint8:
g_u8 = np.clip(g, 0, 255).astype(np.uint8)
else:
g_u8 = g
roi = g_u8[mask]
mean_dn = float(roi.mean()) if roi.size else 0.0
std_dn = float(roi.std()) if roi.size else 0.0
raw = float(focus_measure_edges(g_u8, mask))
# Normalize to reduce exposure/gain dependence (gradient energy scales ~ intensity^2)
norm = raw / ((mean_dn + 1e-6) ** 2)
print(f"AF: mean={mean_dn:.1f} std={std_dn:.1f} raw_focus={raw:.2f} norm_focus={norm:.6f}")
return norm
# ---------
# A) Aerotech GMZ scan (relative to current GMZ = "home" for this autofocus call)
# ---------
aero0 = devs.aerotech_pos
gmz0 = float(aero0.z)
gmz_offsets = np.linspace(-0.5 * z_range_mm, 0.5 * z_range_mm, z_steps, dtype=np.float64)
gmz_scores = []
for dz in gmz_offsets:
devs.aerotech.move_motor_linear("Z", gmz0 + float(dz), 10)
# wait 1 new frame after motion so we don't score an old buffer
start_uid = devs.samcam_frame_id()
t_deadline = time.perf_counter() + 0.25
while time.perf_counter() < t_deadline and devs.samcam_frame_id() == start_uid:
time.sleep(0.001)
gmz_scores.append(score_focus())
gmz_scores = np.asarray(gmz_scores, dtype=np.float64)
best_i = int(np.argmax(gmz_scores))
best_gmz_offset = float(gmz_offsets[best_i])
best_focus = float(gmz_scores[best_i])
# Move GMZ back to "home" (gmz0)
devs.aerotech.move_motor_absolute("Z", gmz0, 10000)
# If best was ~0 anyway, nothing to bake in
if abs(best_gmz_offset) < 1e-6:
print(f"Aerotech prefocus: best_gmz_offset≈0, focus={best_focus:.2f}")
return 0.0
# ---------
# B) Estimate local Jacobian: how Aerotech GMZ changes per unit Smargon beamline-z command
# We do two probe moves in the Smargon command space and measure GMZ readback.
# ---------
def move_smargon_beamline_dz(dz_mm: float) -> None:
sh_new = start_smargon.sh_mm + geom.smargon_nudge(Coordinate(z=float(dz_mm)))
target = SmargonCoordinate(
sh_mm=sh_new,
phi_deg=start_smargon.phi_deg,
chi_deg=start_smargon.chi_deg,
)
devs.smargon_pos = target
devs.smargon_wait(timeout=30)
move_smargon_beamline_dz(best_gmz_offset)
print(
f"Aerotech prefocus: best_gmz_offset={best_gmz_offset*1000} um, focus={best_focus:.2f} "
f"Smargon_start: {start_smargon.sh_mm} um, Smargon_end: {devs.smargon_pos.sh_mm} um"
)
return float(best_gmz_offset)
def focus_measure_laplacian(gray: np.ndarray, mask: np.ndarray | None = None) -> float:
"""
Fast focus metric: variance of Laplacian.
Notes:
- Works best on uint8 images.
- Use a mask/ROI to avoid scoring irrelevant background.
"""
if gray is None:
return 0.0
if gray.ndim != 2:
raise ValueError(f"Expected 2D grayscale image, got shape={gray.shape}")
g = gray
if g.dtype != np.uint8:
g = np.clip(g, 0, 255).astype(np.uint8)
if mask is not None:
roi = g[mask]
if roi.size < 64: # too few pixels -> unstable variance
return 0.0
# Laplacian needs 2D input; reshape ROI to a thin image is awkward.
# Better: compute Laplacian on full image and then mask the result.
lap = cv2.Laplacian(g, cv2.CV_64F, ksize=3)
v = float(lap[mask].var())
return v
lap = cv2.Laplacian(g, cv2.CV_64F, ksize=3)
return float(lap.var())
def _wait_for_new_uid(
get_frame_id: Callable[[], int] | None,
last_uid: int | None,
*,
frames: int = 1,
timeout_s: float = 0.30,
poll_s: float = 0.002,
fallback_sleep_s: float = 0.04,
) -> int | None:
"""
Wait until UniqueId advances by `frames`.
Returns the new uid (or last_uid if we couldn't observe advancement).
"""
if get_frame_id is None:
time.sleep(fallback_sleep_s)
return last_uid
try:
uid0 = int(get_frame_id()) if last_uid is None else int(last_uid)
except Exception:
time.sleep(fallback_sleep_s)
return last_uid
target = uid0 + int(frames)
deadline = time.perf_counter() + float(timeout_s)
while time.perf_counter() < deadline:
try:
uid = int(get_frame_id())
except Exception:
uid = uid0
if uid >= target:
return uid
time.sleep(poll_s)
# Timeout: don't hang autofocus; just do a small sleep to reduce stale-buffer chance.
time.sleep(fallback_sleep_s)
return uid0
def autofocus_gpt(
z_positions: Iterable[float],
move_stage_fn: Callable[[float], None],
*,
get_frame_id: Callable[[], int] | None = None,
wait_for_stop: Callable[[], None] | None = None,
mask: np.ndarray | None = None,
robust_frames: int = 1,
) -> tuple[float, list[tuple[float, float]]]:
"""
Simple autofocus scan with reliability improvements:
- waits for a new UniqueId after motion (avoids scoring stale frames)
- optional median-of-N scoring per z
"""
measures: list[tuple[float, float]] = []
last_uid: int | None = None
# Prime last_uid so the first point also waits for a "fresh" frame
if get_frame_id is not None:
try:
last_uid = int(get_frame_id())
except Exception:
last_uid = None
for z in z_positions:
move_stage_fn(float(z))
if wait_for_stop is not None:
wait_for_stop()
# Wait for camera to deliver a frame AFTER the move
last_uid = _wait_for_new_uid(get_frame_id, last_uid, frames=1, timeout_s=0.35)
if robust_frames <= 1:
img = daq.camera_image_gray
score = focus_measure_laplacian(img, mask=mask)
else:
vals: list[float] = []
for _ in range(int(robust_frames)):
img = daq.camera_image_gray
vals.append(focus_measure_laplacian(img, mask=mask))
last_uid = _wait_for_new_uid(get_frame_id, last_uid, frames=1, timeout_s=0.35)
score = float(np.median(np.asarray(vals, dtype=np.float64)))
measures.append((float(z), float(score)))
print(f"Z={z:.6f}, sharpness={score:.3f}")
best_z = max(measures, key=lambda x: x[1])[0]
return best_z, measures
# ---- Example z positions ----
coarse = np.linspace(-0.1, 0.1, 10) # 0 to 200 microns in 10µm steps
def move_stage(z):
# Insert your hardware code here:
print(z)
devs.aerotech.move_motor_absolute("Z", z, 1000)
#devs.aerotech.controller.read_status()
# e.g. serial.write(f"MOVE Z {z}")
def get_frame_id():
return int(devs.samcam_frame_id())
if __name__ == "__main__":
devs = BeamlineDevices(mx_beamline())
cfg = BeamlineConfig(mx_beamline())
daq = AareDAQ(cfg, bl=mx_beamline())
zoom = devs.zoom
beam_center = cfg.get_beam_mark(zoom)
settings = AutofocusSettings(center_x_pxl=beam_center[0], center_y_pxl=beam_center[1],
radius_pxl=30, z_range_um=400, z_steps=40)
st = time.perf_counter()
best_z, curve = autofocus_gpt(coarse, move_stage, get_frame_id=get_frame_id)
move_stage(0)
#move_stage(best_z)
geom = daq.sample_geometry
start_smargon = devs.smargon_pos
sh_new = start_smargon.sh_mm + geom.smargon_nudge(Coordinate(z=float(best_z)))
target = SmargonCoordinate(
sh_mm=sh_new,
phi_deg=start_smargon.phi_deg,
chi_deg=start_smargon.chi_deg,
)
devs.smargon_pos = target
devs.smargon_wait(timeout=30)
print("Best focus at:", best_z)
print(f"Total time: {time.perf_counter() - st:.5f} s")
+30 -9
View File
@@ -5,11 +5,11 @@ from PySide6.QtCore import QCommandLineParser, QCommandLineOption
from PySide6.QtWidgets import QApplication, QMessageBox
from aaredaqlib.logger_config import setup_logger
from aaregui.main_window import MainWindow
from aare.common.logger_config import setup_logger
from aare.gui.main_window import MainWindow
#from aaregui.widgets.login import LoginDialog
from aaredaqlib.beamline import MXBeamline, mx_beamline
from aaregui.auth import auth
from aare.common.beamline import MXBeamline, mx_beamline
from aare.gui.auth import auth
logger = setup_logger("aareGUI")
@@ -32,18 +32,30 @@ if __name__ == "__main__":
default_url = "http://mx-x06da-queue-01.psi.ch:5210"
default_zmq_addr = "tcp://x06da-pserv-01:9089" #129.129.110.12:9089
default_pred_zmq_addr = "tcp://mx-ml:9091"
default_beamline_cam_addr = "x06da-axis-1.psi.ch"
default_gonio_cam_addr = "axis-accc8ed2972e.psi.ch"
default_gonio_camera_id = 3
case MXBeamline.X10SA:
default_url = "http://mx-x10sa-queue-01.psi.ch:5210"
default_zmq_addr = ""
default_pred_zmq_addr = ""
default_url = "http://127.0.0.1:5210"
default_zmq_addr = "tcp://x10sa-spark-01:9091" #"tcp://x10sa-pserv-01:9089" #
default_pred_zmq_addr = "tcp://x10sa-spark-01:9091" #"tcp://sls-gpu-003:9089"#""
default_beamline_cam_addr = "axis-accc8eb02488.psi.ch"
default_gonio_cam_addr = "axis-accc8ea5e463.psi.ch"
default_gonio_camera_id = 1
case MXBeamline.X06SA:
default_url = "http://mx-x06sa-queue-01.psi.ch:5210"
default_zmq_addr = ""
default_pred_zmq_addr = ""
default_beamline_cam_addr = ""
default_gonio_cam_addr = ""
default_gonio_camera_id = 1
case _:
default_url = ""
default_zmq_addr = ""
default_pred_zmq_addr = ""
default_beamline_cam_addr = ""
default_gonio_cam_addr = ""
default_gonio_camera_id = 1
# Add custom options as needed
urlOption = QCommandLineOption(["u", "aaredaq-url"],
@@ -91,6 +103,11 @@ if __name__ == "__main__":
try:
token = auth(base_url)
if not token or token.count(".") != 2:
raise RuntimeError(
"Authentication did not return a valid token. "
"Please check the server is running (it may still be initialising)."
)
logger.info("Authentication successful")
except Exception as e:
logger.error(f"Cannot connect to AareDAQ server. Exiting. {e}")
@@ -107,7 +124,10 @@ if __name__ == "__main__":
token=token,
default_image=default_image,
zmq_addr=zmq_addr,
pred_zmq_addr=pred_zmq_addr)
pred_zmq_addr=pred_zmq_addr,
beamline_cam_addr = default_beamline_cam_addr,
gonio_cam_addr = default_gonio_cam_addr,
gonio_cam_id = default_gonio_camera_id)
win.show()
sys.exit(app.exec())
except Exception as e:
@@ -118,8 +138,9 @@ if __name__ == "__main__":
QMessageBox.critical(
None,
"Fatal Error",
f"An error occurred during startup:\n\n{str(e)}\n\nSee console for details."
f"An error occurred during startup. See console for details."
f"\nPlease check the server is running and your network connection."
f"\n\n{str(e)}\n\n"
)
except:
pass
@@ -1,42 +1,52 @@
import time
import jwt
from PySide6.QtCore import Qt, Slot, Signal
from PySide6.QtGui import QAction
from PySide6.QtCore import Qt, Slot, Signal, QTimer, QSettings
from PySide6.QtGui import QAction, QPixmap
from PySide6.QtWidgets import (
QMainWindow,
QWidget,
QSplitter,
QHBoxLayout,
QVBoxLayout,
QMessageBox,
QApplication,
QDockWidget, QTabWidget)
QDockWidget,
QTabWidget, QFrame, QSizePolicy, QLabel)
from aaredaqlib.coordinate import Coordinate, SmargonCoordinate
from aaredaqlib.diffraction_geometry import DiffractionGeometry
from aaredaqlib.logger_config import setup_logger
from aaredaqlib.models import SampleShortInfoList, TokenData, DAQStatusModel, BeamlineStateEnum
from aaredaqlib.sample_geometry import SampleGeometryModel
from aaregui.panels.LogPanel import LogDock
from aare.common.coordinate import Coordinate, SmargonCoordinate
from aare.common.diffraction_geometry import DiffractionGeometry
from aare.common.logger_config import setup_logger
from aare.common.models import SampleShortInfoList, TokenData, DAQStatusModel, BeamlineStateEnum
from aare.common.sample_geometry import SampleGeometryModel
from aare.gui.panels.LogPanel import LogDock
from aaregui.panels.beamline_controls import BeamlineControls
from aaregui.panels.data_collection_settings import DataCollectionSettings
from aaregui.panels.manual_sample_panel import ManualSamplePanel
from aaregui.panels.reference_tools_panel import ReferenceToolsPanel
from aaregui.panels.sample_queue_panel import SampleQueuePanel
from aaregui.panels.tell_sample_panel import TellSamplePanel
from aaregui.panels.face_detection_panel import FaceDetectionPanel
from aaregui.scan_logic.raster_grid_manager import RasterGridManager
from aaregui.scan_logic.rotation_scan_manager import RotationScanManager
from aaregui.scan_logic.sample_mount_logic import SampleMountLogic
from aaregui.threads.axis_video_thread import VideoThread
from aaregui.threads.camera_thread import SampleCameraThread, PredictionSubscriber
from aaregui.threads.daq_worker import DAQWorker
from aaregui.threads.jfjoch_viewer import JFJochDBusClient
from aaregui.widgets.camera_image import SampleCameraImageLabel
from aaregui.widgets.no_wheel_scroll_area import NoWheelScrollArea
from aaregui.widgets.status_bar import StatusBar
from aaregui.widgets.video_image import VideoGraphicsView
from aaregui.panels.fluorescence_panel import FluorescencePanel
from aare.gui.panels.beamline_controls import BeamlineControls
from aare.gui.panels.data_collection_settings import DataCollectionSettings
from aare.gui.panels.developer_help_dialog import DeveloperHelpDialog
from aare.gui.panels.beamline_recovery_panel import BeamlineRecoveryDialog
from aare.gui.panels.manual_sample_panel import ManualSamplePanel
from aare.gui.panels.reference_tools_panel import ReferenceToolsPanel
from aare.gui.panels.sample_queue_panel import SampleQueuePanel
from aare.gui.panels.tell_sample_panel import TellSamplePanel
from aare.gui.panels.face_detection_panel import FaceDetectionPanel
from aare.gui.panels.smargon_trace_panel import SmargonTracePanel
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
from aare.gui.scan_logic.rotation_scan_manager import RotationScanManager
from aare.gui.scan_logic.sample_mount_logic import SampleMountLogic
from aare.gui.threads.axis_video_thread import VideoThread
from aare.gui.tutorials.tutorial_manager import TutorialManager, TutorialStep
from aare.gui.tutorials.controls_help_dialog import ControlsHelpDialog
from aare.gui.threads.camera_thread import SampleCameraThread
from aare.gui.threads.prediction_subscriber import PredictionSubscriber
from aare.gui.threads.daq_worker import DAQWorker
from aare.gui.threads.jfjoch_viewer import JFJochDBusClient
from aare.gui.tutorials.tutorial_registration import register_tutorials
from aare.gui.widgets.alert_banner import AlertBanner
from aare.gui.widgets.camera_image import SampleCameraImageLabel
from aare.gui.widgets.no_wheel_scroll_area import NoWheelScrollArea
from aare.gui.widgets.status_bar import StatusBar
from aare.gui.widgets.video_image import VideoGraphicsView
from aare.gui.panels.fluorescence_panel import FluorescencePanel
logger = setup_logger("aareGUI")
@@ -47,29 +57,65 @@ class MainWindow(QMainWindow):
token: str,
default_image: str | None,
zmq_addr: str | None,
pred_zmq_addr: str | None):
pred_zmq_addr: str | None,
beamline_cam_addr: str | None,
gonio_cam_addr: str | None,
gonio_cam_id: int | None
):
super().__init__()
self.__base_url = base_url
self.__token = token
self.__mounting = False
self._dev_help_dialog = None
self._beamline_recovery_dialog = None
self._controls_help_dialog = None
self._cleanup_done = False
# Tutorial manager (define tutorials after widgets exist)
self.tutorial_manager = TutorialManager(self)
self.viewer = JFJochDBusClient()
# Decode the JWT without signature verification
self.__decoded_token = TokenData(**jwt.decode(token, options={"verify_signature": False}))
logger.debug(self.__decoded_token)
try:
token_str = (token or "").strip()
if token_str.count(".") != 2:
raise ValueError(
"Invalid authentication token received (not a JWT). "
"This usually happens when the server is not running or still starting."
)
payload = jwt.decode(token_str, options={"verify_signature": False})
self.__decoded_token = TokenData(**payload)
except Exception as e:
logger.error(f"Failed to decode authentication token: {e}", exc_info=True)
QMessageBox.critical(
None,
"Authentication Error",
"Could not start the GUI because authentication data was invalid.\n\n"
"Most commonly the server is not running yet (or is still initialising).\n"
"Please start/restart the server and try again."
)
raise
self.setStyleSheet("background-color: rgb(216, 228, 253);")
top_widget = QWidget(parent=self)
root_widget = QWidget(parent=self)
root_layout = QVBoxLayout(root_widget)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
self.alert_banner = AlertBanner(parent=root_widget)
root_layout.addWidget(self.alert_banner)
top_widget = QWidget(parent=root_widget)
top_widget_layout = QHBoxLayout(top_widget)
top_widget.setLayout(top_widget_layout)
diffraction = DiffractionGeometry(
energy_keV=12.4,
dtz_mm=100,
detector_size_pxl=(1553,1630),
pixel_size_mm=0.150, #PILATUS 4
detector_size_pxl=(1553, 1630),
pixel_size_mm=0.150, # PILATUS 4
beam_center_pxl=(750, 750),
detector_description="PILATUS 4",
detector_serial_number="1",
@@ -77,7 +123,7 @@ class MainWindow(QMainWindow):
poni_rot2_rad=-0.003839724
)
geom = SampleGeometryModel(beam_location_pxl=Coordinate(x=1000,y=1000),
geom = SampleGeometryModel(beam_location_pxl=Coordinate(x=1000, y=1000),
pixel_in_mm=0.001,
aerotech=Coordinate(),
smargon=SmargonCoordinate(sh_mm=Coordinate(), phi_deg=0, chi_deg=0),
@@ -96,7 +142,6 @@ class MainWindow(QMainWindow):
raster_mgr=self.raster,
diffraction=diffraction)
top_widget_layout.addWidget(collection_controls_scroll)
collection_controls_scroll.setWidget(self.data_collection)
collection_controls_scroll.setHorizontalScrollBarPolicy(
@@ -104,14 +149,14 @@ class MainWindow(QMainWindow):
)
collection_controls_scroll.setFixedWidth(self.data_collection.set_width + 10)
self.video_tab = QTabWidget(parent=top_widget)
self.sample_camera = SampleCameraImageLabel(geom=geom, raster=self.raster, parent=top_widget, default_image=default_image)
self.sample_camera = SampleCameraImageLabel(geom=geom, raster=self.raster, parent=top_widget,
default_image=default_image)
self.beamline_view_container = QWidget(parent=top_widget)
self.beamline_view_layout = QVBoxLayout(self.beamline_view_container)
self.beamline_view_layout.setContentsMargins(0,0,0,0)
self.beamline_view_layout.setContentsMargins(0, 0, 0, 0)
self.beamline_view_layout.setSpacing(6)
self.beamline_view_1_combined = VideoGraphicsView()
@@ -120,18 +165,20 @@ class MainWindow(QMainWindow):
self.beamline_view_layout.addWidget(self.beamline_view_2_combined)
self.beamline_view = VideoGraphicsView()
self.beamline_camera_thread = VideoThread(ip="x06da-axis-1.psi.ch")
self.beamline_camera_thread.frame_ready.connect(self.beamline_view.update_frame)
self.beamline_camera_thread.frame_ready.connect(self.beamline_view_2_combined.update_frame)
self.beamline_camera_thread.start()
if beamline_cam_addr:
self.beamline_camera_thread = VideoThread(ip=beamline_cam_addr)
self.beamline_camera_thread.frame_ready.connect(self.beamline_view.update_frame)
self.beamline_camera_thread.frame_ready.connect(self.beamline_view_2_combined.update_frame)
self.beamline_camera_thread.start()
self.beamline_view_layout.addWidget(self.beamline_view)
self.gonio_view = VideoGraphicsView()
#TODO add option to change cameras for gonio_camera_thread
self.gonio_camera_thread = VideoThread(ip="axis-accc8ed2972e.psi.ch", camera=3)
self.gonio_camera_thread.frame_ready.connect(self.gonio_view.update_frame)
self.gonio_camera_thread.frame_ready.connect(self.beamline_view_1_combined.update_frame)
self.gonio_camera_thread.start()
# TODO add option to change cameras for gonio_camera_thread
if gonio_cam_addr and gonio_cam_id:
self.gonio_camera_thread = VideoThread(ip=gonio_cam_addr, camera=gonio_cam_id)
self.gonio_camera_thread.frame_ready.connect(self.gonio_view.update_frame)
self.gonio_camera_thread.frame_ready.connect(self.beamline_view_1_combined.update_frame)
self.gonio_camera_thread.start()
self.beamline_view_layout.addWidget(self.gonio_view)
self.video_tab.addTab(self.sample_camera, "Sample camera")
@@ -151,8 +198,8 @@ class MainWindow(QMainWindow):
)
beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 10)
self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s = []))
self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s = []))
self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s=[]))
self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[]))
self.job_list_panel = SampleQueuePanel()
self.tell_samples_dock = QDockWidget("Sample List", self)
@@ -162,6 +209,7 @@ class MainWindow(QMainWindow):
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.tell_samples_dock)
self.ref_tools_dock = QDockWidget("Reference Tools", self)
self.ref_tools_dock.setObjectName("ref_tools_dock")
self.ref_tools_dock.setWidget(self.ref_tools_panel)
self.ref_tools_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.ref_tools_dock)
@@ -170,6 +218,7 @@ class MainWindow(QMainWindow):
self.sample_logic = SampleMountLogic()
self.job_list_dock = QDockWidget("Automation list", self)
self.job_list_dock.setObjectName("job_list_dock")
self.job_list_dock.setWidget(self.job_list_panel)
self.job_list_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.job_list_dock)
@@ -177,36 +226,58 @@ class MainWindow(QMainWindow):
self.manual_sample_panel = ManualSamplePanel()
self.manual_sample_dock = QDockWidget("Manual sample", self)
self.manual_sample_dock.setObjectName("manual_sample_dock")
self.manual_sample_dock.setWidget(self.manual_sample_panel)
self.manual_sample_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.manual_sample_dock)
self.face_panel = FaceDetectionPanel()
self.face_panel_dock = QDockWidget("Face detection", self)
self.face_panel_dock.setObjectName("face_panel_dock")
self.face_panel_dock.setWidget(self.face_panel)
self.face_panel_dock.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea | Qt.DockWidgetArea.LeftDockWidgetArea)
self.face_panel_dock.setAllowedAreas(
Qt.DockWidgetArea.RightDockWidgetArea | Qt.DockWidgetArea.LeftDockWidgetArea)
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.face_panel_dock)
self.face_panel_dock.hide()
self.fluor_panel = FluorescencePanel()
self.fluor_panel_dock = QDockWidget("Fluorescence", self)
self.fluor_panel_dock.setObjectName("fluor_panel_dock")
self.fluor_panel_dock.setWidget(self.fluor_panel)
self.fluor_panel_dock.setAllowedAreas(Qt.DockWidgetArea.TopDockWidgetArea | Qt.DockWidgetArea.BottomDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
self.fluor_panel_dock.setAllowedAreas(
Qt.DockWidgetArea.TopDockWidgetArea | Qt.DockWidgetArea.BottomDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.fluor_panel_dock)
self.fluor_panel_dock.hide()
# Create and add the dock to your main window
self.log_dock = LogDock("Console Log", self)
self.log_dock.setObjectName("log_dock")
self.addDockWidget(Qt.BottomDockWidgetArea, self.log_dock)
self.log_dock.attach_logger("")
self.log_dock.attach_logger("aareDAQ")
self.log_dock.attach_logger("aareGUI")
self.log_dock.hide()
self.setCentralWidget(top_widget)
self.smargon_trace_panel = SmargonTracePanel()
self.smargon_trace_dock = QDockWidget("Smargon trace", self)
self.smargon_trace_dock.setObjectName("smargon_trace_dock")
self.smargon_trace_dock.setWidget(self.smargon_trace_panel)
self.smargon_trace_dock.setAllowedAreas(
Qt.DockWidgetArea.RightDockWidgetArea
| Qt.DockWidgetArea.LeftDockWidgetArea
| Qt.DockWidgetArea.BottomDockWidgetArea
)
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.smargon_trace_dock)
self.smargon_trace_dock.hide()
root_layout.addWidget(top_widget)
self.setCentralWidget(root_widget)
self.setWindowTitle("AareGUI")
self.create_menu_bar()
self._restore_window_state()
# Define tutorials now that the UI exists
self.status_bar = StatusBar(self.__decoded_token, parent=self)
self.setStatusBar(self.status_bar)
@@ -217,6 +288,7 @@ class MainWindow(QMainWindow):
self.daq.reference_tools.connect(self.ref_tools_panel.new_list)
self.beamline.samcam.changed.connect(self.daq.samcam_settings)
self.beamline.samcam.screenshot_requested.connect(self.daq.send_screenshot_db)
self.beamline.loopctr.background.clicked.connect(self.daq.alc_background)
self.beamline.loopctr.find_tip.clicked.connect(self.daq.center_loop)
self.beamline.loopctr.bounding_box.clicked.connect(self.daq.ml_bounding_box)
@@ -229,10 +301,13 @@ class MainWindow(QMainWindow):
self.raster.omega.connect(self.daq.set_omega)
self.raster.smargon.connect(self.daq.move_smargon)
self.beamline.omega_panel.set_omega_rel.connect(self.daq.set_omega_rel)
self.beamline.omega_panel.set_omega.connect(self.daq.set_omega)
self.sample_camera.set_omega.connect(self.daq.set_omega)
self.beamline.zoom_panel.zoom.connect(self.daq.zoom)
self.beamline.illumination_panel.light.connect(self.daq.light)
self.beamline.illumination_panel.front_light.connect(self.daq.front_light)
self.beamline.illumination_panel.back_light.connect(self.daq.back_light)
if self.__decoded_token.staff:
self.beamline.abr_tweak.abr_tweak.connect(self.daq.abr_tweak)
self.beamline.abr_tweak.abr_save.connect(self.daq.abr_save)
@@ -248,25 +323,38 @@ class MainWindow(QMainWindow):
if zmq_addr is not None:
self.camera_thread = SampleCameraThread(zmq_url=zmq_addr)
self.camera_thread.camera_image.connect(self.sample_camera.update_pixmap)
self.camera_thread.start()
self.camera_thread.focus_measure.connect(self.status_bar.update_sharpness)
self.camera_thread.fps_measure.connect(self.status_bar.update_samcam_fps)
else:
self.camera_thread = None
# Prediction subscriber thread
if pred_zmq_addr is not None:
logger.debug(f"Starting prediction subscriber thread {pred_zmq_addr}")
self.prediction_thread = PredictionSubscriber(pred_zmq_url=pred_zmq_addr)
self.prediction_thread = PredictionSubscriber(pred_zmq_url=pred_zmq_addr, topic="detections")
self.prediction_thread.prediction.connect(self.sample_camera.update_detections)
self.prediction_thread.start()
else:
self.prediction_thread = None
self._last_pred_image_ts: float | None = None
self._pred_preferred_timeout_s: float = 0.7 # tune: how long we "trust" prediction images
self._pred_is_preferred: bool = False
QApplication.instance().aboutToQuit.connect(self.cleanup)
if self.camera_thread is not None:
self.camera_thread.camera_image.connect(self._on_samcam_camera_pixmap)
if self.prediction_thread is not None:
self.prediction_thread.image.connect(self._on_samcam_prediction_pixmap)
self._samcam_source_timer = QTimer(self)
self._samcam_source_timer.setInterval(200) # ms
self._samcam_source_timer.timeout.connect(self._update_samcam_source_preference)
self._samcam_source_timer.start()
#
# self.data_collection.helical.helical_scan.connect(self.worker.helical_scan)
# self.data_collection.helical.helical_scan.connect(self.worker.helical_scan)
# self.data_collection.helical.update_bookmarks.connect(
# self.camera_image.update_bookmarks
# )
@@ -336,6 +424,7 @@ class MainWindow(QMainWindow):
self.daq.update.connect(self.sample_camera.update_daq_status)
self.daq.update.connect(self.tell_samples.update_daq_status)
self.daq.update.connect(self.ref_tools_panel.update_daq_status)
self.daq.update.connect(self.camera_thread.update_daq_status)
if self.__decoded_token.staff:
self.daq.update.connect(self.beamline.beam_size.update_daq_status)
@@ -368,6 +457,42 @@ class MainWindow(QMainWindow):
self.daq.fluorimeter_spectrum_update.connect(self.fluor_panel.update_plot)
self.daq.fluorimeter_spectrum_update.connect(lambda: self.fluor_panel_dock.setVisible(True))
self.daq.status_message.connect(self.status_bar.show_connection_message)
self.daq.status_message.connect(self.alert_banner.show_message)
register_tutorials(self, self.tutorial_manager)
@Slot(QPixmap)
def _on_samcam_prediction_pixmap(self, pix: QPixmap) -> None:
self._last_pred_image_ts = time.monotonic()
self._pred_is_preferred = True
self.sample_camera.update_pixmap(pix)
@Slot(QPixmap)
def _on_samcam_camera_pixmap(self, pix: QPixmap) -> None:
# Only show camera frames when prediction is not currently "healthy"
if not self._pred_is_preferred:
self.sample_camera.update_pixmap(pix)
@Slot()
def _update_samcam_source_preference(self) -> None:
if self.prediction_thread is None:
self._pred_is_preferred = False
return
if self._last_pred_image_ts is None:
self._pred_is_preferred = False
return
age_s = time.monotonic() - self._last_pred_image_ts
self._pred_is_preferred = age_s <= self._pred_preferred_timeout_s
def start_text_tutorial(self) -> None:
self.tutorial_manager.start("intro_text")
def start_interactive_tutorial(self) -> None:
self.tutorial_manager.start("intro_interactive")
def create_menu_bar(self):
"""Create a menu bar with File->Quit and Help->About."""
# Main menu bar
@@ -420,6 +545,15 @@ class MainWindow(QMainWindow):
self.fluor_panel_dock.visibilityChanged.connect(show_fluor_panel_action.setChecked)
view_menu.addAction(show_fluor_panel_action)
show_smargon_trace_action = QAction("Show Smargon trace", self)
show_smargon_trace_action.setCheckable(True)
show_smargon_trace_action.setChecked(False)
show_smargon_trace_action.triggered.connect(lambda checked: self.smargon_trace_dock.setVisible(checked))
self.smargon_trace_dock.visibilityChanged.connect(
lambda visible: self.smargon_trace_panel.refresh_plot(force=True) if visible else None
)
view_menu.addAction(show_smargon_trace_action)
show_log_action = QAction("Show Log", self)
show_log_action.setCheckable(True)
show_log_action.setChecked(False)
@@ -432,6 +566,29 @@ class MainWindow(QMainWindow):
about_action.triggered.connect(self.show_about_dialog)
help_menu.addAction(about_action)
controls_help_action = QAction("Mouse / Keyboard Controls", self)
controls_help_action.triggered.connect(self.show_controls_help)
help_menu.addAction(controls_help_action)
dev_help_action = QAction("Developer / Help", self)
dev_help_action.triggered.connect(self.show_developer_help)
help_menu.addAction(dev_help_action)
if self.__decoded_token.staff:
beamline_recovery_action = QAction("Beamline Recovery", self)
beamline_recovery_action.triggered.connect(self.show_beamline_recovery)
help_menu.addAction(beamline_recovery_action)
help_menu.addSeparator()
start_text_tutorial_action = QAction("Start Tutorial (Text)", self)
start_text_tutorial_action.triggered.connect(self.start_text_tutorial)
help_menu.addAction(start_text_tutorial_action)
start_interactive_tutorial_action = QAction("Start Tutorial (Interactive)", self)
start_interactive_tutorial_action.triggered.connect(self.start_interactive_tutorial)
help_menu.addAction(start_interactive_tutorial_action)
def show_about_dialog(self):
QMessageBox.about(
self,
@@ -439,6 +596,37 @@ class MainWindow(QMainWindow):
"Aare Macromolecular Crystallography GUI\nVersion: 1.0\nCopyright: Paul Scherrer Institute 2024-2025",
)
def show_controls_help(self) -> None:
if self._controls_help_dialog is None:
self._controls_help_dialog = ControlsHelpDialog(parent=self)
self._controls_help_dialog.show()
self._controls_help_dialog.raise_()
self._controls_help_dialog.activateWindow()
def show_developer_help(self) -> None:
if self._dev_help_dialog is None:
self._dev_help_dialog = DeveloperHelpDialog(
daq=self.daq,
is_staff=bool(getattr(self.__decoded_token, "staff", False)),
parent=self,
)
self._dev_help_dialog.refresh()
self._dev_help_dialog.show()
self._dev_help_dialog.raise_()
self._dev_help_dialog.activateWindow()
def show_beamline_recovery(self) -> None:
if not bool(getattr(self.__decoded_token, "staff", False)):
return
if self._beamline_recovery_dialog is None:
self._beamline_recovery_dialog = BeamlineRecoveryDialog(
daq=self.daq,
parent=self,
)
self._beamline_recovery_dialog.show()
self._beamline_recovery_dialog.raise_()
self._beamline_recovery_dialog.activateWindow()
@Slot(str)
def show_sample_missing_dialog(self, msg: str):
if self.job_list_panel.is_running():
@@ -456,8 +644,12 @@ class MainWindow(QMainWindow):
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
self.beamline_camera_thread.set_busy(s.busy)
self.gonio_camera_thread.set_busy(s.busy)
if hasattr(self, "beamline_camera_thread") and self.beamline_camera_thread is not None:
self.beamline_camera_thread.set_busy(s.busy)
if hasattr(self, "gonio_camera_thread") and self.gonio_camera_thread is not None:
self.gonio_camera_thread.set_busy(s.busy)
if not self.__mounting and s.state == BeamlineStateEnum.RobotSampleExchange:
self.__mounting = True
self.video_tab.setCurrentIndex(3)
@@ -465,17 +657,57 @@ class MainWindow(QMainWindow):
self.__mounting = False
self.video_tab.setCurrentIndex(0)
@Slot(str)
def display_error(self, msg: str):
self.status_bar.setStyleSheet("color: red;")
self.status_bar.showMessage(f'<span style="color: red; "> {msg} </span>', 10000)
def _restore_window_state(self) -> None:
settings = QSettings()
geometry = settings.value("main_window/geometry")
state = settings.value("main_window/state")
if geometry is not None:
self.restoreGeometry(geometry)
if state is not None:
self.restoreState(state)
def closeEvent(self, event) -> None:
try:
settings = QSettings()
settings.setValue("main_window/geometry", self.saveGeometry())
settings.setValue("main_window/state", self.saveState())
except Exception as e:
logger.warning(f"Failed to save main window state: {e}")
try:
self.cleanup()
except Exception as e:
logger.warning(f"Cleanup during closeEvent failed: {e}")
super().closeEvent(event)
def cleanup(self):
if self.camera_thread is not None:
self.camera_thread.stop()
if self.prediction_thread is not None:
self.prediction_thread.stop()
if self.beamline_camera_thread is not None:
self.beamline_camera_thread.stop()
if self.gonio_camera_thread is not None:
self.gonio_camera_thread.stop()
if getattr(self, "_cleanup_done", False):
return
self._cleanup_done = True
try:
if hasattr(self, "_samcam_source_timer") and self._samcam_source_timer is not None:
self._samcam_source_timer.stop()
except Exception as e:
logger.warning(f"Failed to stop _samcam_source_timer: {e}")
for attr_name in (
"camera_thread",
"prediction_thread",
"beamline_camera_thread",
"gonio_camera_thread",
):
thread = getattr(self, attr_name, None)
if thread is None:
continue
logger.debug(f"Stopping {attr_name}")
try:
thread.stop()
except Exception as e:
logger.warning(f"Failed to stop {attr_name}: {e}")
setattr(self, attr_name, None)
@@ -2,7 +2,7 @@ from typing import Literal
from PySide6.QtGui import QColor
from aaredaqlib.coordinate import SmargonCoordinate
from aare.common.coordinate import SmargonCoordinate
class SmargonBookmark:
@@ -1,7 +1,7 @@
from PySide6.QtCore import QAbstractTableModel, Qt, Slot
from PySide6.QtCore import QAbstractTableModel, Qt
from PySide6.QtGui import QBrush, QColor
from aaredaqlib.models import SampleShortInfo, SampleShortInfoList
from aare.common.models import SampleShortInfo, SampleShortInfoList
def get_entry(sample: SampleShortInfo, column: int):
@@ -3,7 +3,7 @@ import re
from PySide6.QtCore import QAbstractTableModel, Qt, QMimeData
from PySide6.QtGui import QBrush, QColor
from aaredaqlib.models import SampleShortInfo, SampleShortInfoList
from aare.common.models import SampleShortInfo, SampleShortInfoList
def get_entry(sample: SampleShortInfo, column: int):
@@ -1,9 +1,9 @@
# Python
from PySide6.QtCore import Qt, Signal, QObject
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QDockWidget, QPlainTextEdit
from aaredaqlib.logger_config import QtLogEmitter, QtLogHandler, find_existing_formatter, attach_to_logger
from aare.common.logger_config import QtLogEmitter, QtLogHandler, find_existing_formatter, attach_to_logger
class LogDock(QDockWidget):
@@ -1,12 +1,12 @@
from PySide6.QtCore import Signal, Slot
from PySide6.QtGui import Qt
from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QPushButton
from aaredaqlib.coordinate import Coordinate
from aaredaqlib.models import DAQStatusModel
from aare.common.coordinate import Coordinate
from aare.common.models import DAQStatusModel
from aaregui.widgets.button_with_payload import ButtonWithPayload
from aaregui.widgets.number_line_edit import NumberLineEdit
from aaregui.widgets.title_label import TitleLabel
from aare.gui.widgets.button_with_payload import ButtonWithPayload
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
DEFAULT_ABR_STEP_UM = 5
@@ -103,7 +103,7 @@ class AbrTweakWidget(QWidget):
goto_button = QPushButton("Go to meas.")
grid_layout.addWidget(goto_button, 4, 0, 1, 3)
save_button.pressed.connect(self.goto_button_pressed)
goto_button.pressed.connect(self.goto_button_pressed)
@Slot()
def goto_button_pressed(self):
@@ -1,9 +1,9 @@
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QWidget, QGridLayout, QLabel
from aaredaqlib.models import DAQStatusModel
from aaregui.widgets.number_line_edit import NumberLineEdit
from aaregui.widgets.title_label import TitleLabel
from aare.common.models import DAQStatusModel
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
class BeamCenterWidget(QWidget):
@@ -1,8 +1,8 @@
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QWidget, QGridLayout, QPushButton, QLabel
from aaredaqlib.models import DAQStatusModel
from aaregui.widgets.title_label import TitleLabel
from aare.common.models import DAQStatusModel
from aare.gui.widgets.title_label import TitleLabel
class BeamMarkWidget(QWidget):
@@ -1,9 +1,9 @@
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QWidget, QGridLayout, QLabel
from aaredaqlib.models import DAQStatusModel
from aaregui.widgets.number_line_edit import NumberLineEdit
from aaregui.widgets.title_label import TitleLabel
from aare.common.models import DAQStatusModel
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
class BeamSizeWidget(QWidget):
@@ -1,16 +1,15 @@
from PySide6.QtWidgets import QFrame, QVBoxLayout
from aaregui.panels.abr_tweak_panel import AbrTweakWidget
from aaregui.panels.beam_center_panel import BeamCenterWidget
from aaregui.panels.beam_mark_panel import BeamMarkWidget
from aaregui.panels.beam_size_panel import BeamSizeWidget
from aaregui.panels.beamline_state_panel import BeamlineStatePanel
from aaregui.panels.illumination_panel import IlluminationPanel
from aaregui.panels.loop_centering_panel import LoopCenteringPanel
from aaregui.panels.omega_panel import OmegaPanel
from aaregui.panels.samcam_panel import SamcamPanel
from aaregui.panels.smargon_panel import SmargonPanel
from aaregui.panels.zoom_panel import ZoomPanel
from aare.gui.panels.abr_tweak_panel import AbrTweakWidget
from aare.gui.panels.beam_center_panel import BeamCenterWidget
from aare.gui.panels.beam_mark_panel import BeamMarkWidget
from aare.gui.panels.beam_size_panel import BeamSizeWidget
from aare.gui.panels.illumination_panel import IlluminationPanel
from aare.gui.panels.loop_centering_panel import LoopCenteringPanel
from aare.gui.panels.omega_panel import OmegaPanel
from aare.gui.panels.samcam_panel import SamcamPanel
from aare.gui.panels.smargon_panel import SmargonPanel
from aare.gui.panels.zoom_panel import ZoomPanel
class BeamlineControls(QFrame):
@@ -0,0 +1,327 @@
from __future__ import annotations
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QPushButton,
QWidget,
QDialogButtonBox,
QInputDialog,
QLineEdit,
QMessageBox,
)
from aare.common.models import DAQStatusModel
from aare.gui.threads.daq_worker import DAQWorker
class RecoveryPanel(QWidget):
def __init__(self, *, daq: DAQWorker, parent=None):
super().__init__(parent)
self._daq = daq
self._last_status: DAQStatusModel | None = None
layout = QVBoxLayout(self)
layout.setSpacing(10)
self._warning_primary = QLabel(
"⚠ Recovery actions are staff-only and intentionally dangerous.",
self,
)
self._warning_primary.setWordWrap(True)
self._warning_primary.setStyleSheet(
"QLabel {"
" background: #fff3cd;"
" color: #7a4b00;"
" border: 1px solid #f0c36d;"
" border-radius: 6px;"
" padding: 8px;"
" font-weight: 600;"
"}"
)
layout.addWidget(self._warning_primary)
self._warning_secondary = QLabel(
"Only use these commands when beamline is stuck and certain beamline is unrecoverable through normal operation.",
self,
)
self._warning_secondary.setWordWrap(True)
self._warning_secondary.setStyleSheet(
"QLabel {"
" background: #fdeaea;"
" color: #8b1e1e;"
" border: 1px solid #e6a8a8;"
" border-radius: 6px;"
" padding: 8px;"
" font-weight: 600;"
"}"
)
layout.addWidget(self._warning_secondary)
self._status = QLabel("Current status: waiting for DAQ status update…", self)
self._status.setWordWrap(True)
self._status.setStyleSheet(
"QLabel {"
" background: #fafafa;"
" border: 1px solid #d0d0d0;"
" border-radius: 6px;"
" padding: 8px;"
"}"
)
layout.addWidget(self._status)
self._last_action = QLabel("Last action: -", self)
self._last_action.setWordWrap(True)
self._last_action.setStyleSheet(
"QLabel {"
" background: #eef6ff;"
" color: #12406a;"
" border: 1px solid #a8c7e6;"
" border-radius: 6px;"
" padding: 8px;"
" font-weight: 600;"
"}"
)
layout.addWidget(self._last_action)
self._take_over_btn = QPushButton("Take over beamline", self)
self._take_over_btn.setStyleSheet(
"QPushButton {"
" background: #fff3cd;"
" border: 1px solid #f0c36d;"
" border-radius: 6px;"
" padding: 8px;"
" font-weight: 600;"
"}"
)
self._take_over_btn.clicked.connect(self._take_over_beamline)
layout.addWidget(self._take_over_btn)
self._free_beamline_btn = QPushButton("Free beamline", self)
self._free_beamline_btn.setStyleSheet(
"QPushButton {"
" background: #fff3cd;"
" border: 1px solid #f0c36d;"
" border-radius: 6px;"
" padding: 8px;"
" font-weight: 600;"
"}"
)
self._free_beamline_btn.clicked.connect(self._free_beamline)
layout.addWidget(self._free_beamline_btn)
self._recover_beamline_btn = QPushButton("Recover beamline", self)
self._recover_beamline_btn.setStyleSheet(
"QPushButton {"
" background: #fdeaea;"
" color: #8b1e1e;"
" border: 1px solid #e6a8a8;"
" border-radius: 6px;"
" padding: 8px;"
" font-weight: 700;"
"}"
)
self._recover_beamline_btn.clicked.connect(self._recover_beamline)
layout.addWidget(self._recover_beamline_btn)
self._recovery_unmount_btn = QPushButton("Unmount sample (recovery)", self)
self._recovery_unmount_btn.setStyleSheet(
"QPushButton {"
" background: #fdeaea;"
" color: #8b1e1e;"
" border: 1px solid #e6a8a8;"
" border-radius: 6px;"
" padding: 8px;"
" font-weight: 700;"
"}"
)
self._recovery_unmount_btn.clicked.connect(self._recovery_unmount_sample)
layout.addWidget(self._recovery_unmount_btn)
self._resync_sample_btn = QPushButton("Resync sample from TELL", self)
self._resync_sample_btn.setStyleSheet(
"QPushButton {"
" background: #eef6ff;"
" color: #12406a;"
" border: 1px solid #a8c7e6;"
" border-radius: 6px;"
" padding: 8px;"
" font-weight: 600;"
"}"
)
self._resync_sample_btn.clicked.connect(self._resync_sample)
layout.addWidget(self._resync_sample_btn)
layout.addStretch(1)
self._daq.update.connect(self._set_daq_status)
self._daq.sample_resync_completed.connect(self._set_last_action)
self._refresh_buttons()
def _prompt_recovery_code(self, action_name: str) -> str | None:
code, ok = QInputDialog.getText(
self,
action_name,
"Enter recovery confirmation code:",
QLineEdit.EchoMode.Password,
)
if not ok:
return None
code = code.strip()
return code or None
def _sample_appears_mounted(self) -> bool:
try:
return self._last_status is not None and self._last_status.sample is not None
except Exception:
return False
def _beamline_appears_busy(self) -> bool:
try:
return self._last_status is not None and bool(self._last_status.busy)
except Exception:
return False
def _status_text(self) -> str:
if self._last_status is None:
return "Current status: waiting for DAQ status update…"
state_name = getattr(self._last_status.state, "name", str(self._last_status.state))
busy = bool(getattr(self._last_status, "busy", False))
sample_mounted = self._sample_appears_mounted()
tell_connected = bool(getattr(self._last_status, "tell_connected", False))
return (
f"State: {state_name}\n"
f"Busy: {busy}\n"
f"Sample mounted: {sample_mounted}\n"
f"TELL connected: {tell_connected}"
)
def _refresh_buttons(self) -> None:
sample_mounted = self._sample_appears_mounted()
beamline_busy = self._beamline_appears_busy()
self._recovery_unmount_btn.setEnabled(sample_mounted)
self._recovery_unmount_btn.setToolTip(
"" if sample_mounted else "Disabled because no mounted sample is visible in current status."
)
self._free_beamline_btn.setEnabled(beamline_busy)
self._free_beamline_btn.setToolTip(
"" 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.")
def _confirm(self, title: str, msg: str) -> bool:
reply = QMessageBox.warning(
self,
title,
msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
return reply == QMessageBox.StandardButton.Yes
@Slot(DAQStatusModel)
def _set_daq_status(self, s: DAQStatusModel) -> None:
self._last_status = s
self._status.setText(self._status_text())
self._refresh_buttons()
@Slot()
def _take_over_beamline(self) -> None:
if not self._confirm(
"Take over beamline",
"This will forcefully grab the active beamline session.\n\nDo you want to continue?",
):
return
code = self._prompt_recovery_code("Take over beamline")
if not code:
return
self._last_action.setText("Last action: Taking over beamline session...")
self._daq.take_over_beamline(code)
@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?",
):
return
code = self._prompt_recovery_code("Free beamline")
if not code:
return
self._last_action.setText("Last action: Clearing beamline busy flag...")
self._daq.free_beamline(code)
@Slot()
def _recover_beamline(self) -> None:
if self._sample_appears_mounted():
if not self._confirm(
"Recover beamline",
"A sample appears to be mounted.\n\n"
"Recovering the beamline may damage the sample or leave hardware in an unsafe state.\n\n"
"Only continue if you are sure this is the correct recovery action.",
):
return
else:
if not self._confirm(
"Recover beamline",
"This will take over the beamline, clear the busy flag, and set the state to Maintenance.\n\n"
"Do you want to continue?",
):
return
code = self._prompt_recovery_code("Recover beamline")
if not code:
return
self._last_action.setText("Last action: Recovering beamline to Maintenance...")
self._daq.recover_beamline(code)
@Slot()
def _recovery_unmount_sample(self) -> None:
if not self._confirm(
"Unmount sample (recovery)",
"This will force-take the session and attempt a controlled recovery unmount.\n\n"
"Use this only if normal unmount is not possible.",
):
return
code = self._prompt_recovery_code("Unmount sample (recovery)")
if not code:
return
self._last_action.setText("Last action: Performing recovery sample unmount...")
self._daq.recovery_unmount_sample(code)
@Slot()
def _resync_sample(self) -> None:
self._last_action.setText("Last action: Resyncing sample cache from TELL...")
self._daq.resync_sample()
@Slot(str)
def _set_last_action(self, message: str) -> None:
self._last_action.setText(f"Last action: {message}")
class BeamlineRecoveryDialog(QDialog):
def __init__(self, *, daq: DAQWorker, parent=None):
super().__init__(parent)
self.setWindowTitle("Beamline Recovery")
self.setMinimumSize(560, 420)
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
self._panel = RecoveryPanel(daq=daq, parent=self)
layout.addWidget(self._panel, 1)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
layout.addWidget(buttons)
@@ -1,6 +1,6 @@
from PySide6.QtWidgets import QWidget, QGridLayout, QPushButton
from aaregui.widgets.title_label import TitleLabel
from aare.gui.widgets.title_label import TitleLabel
class BeamlineStatePanel(QWidget):
@@ -3,23 +3,19 @@ from PySide6.QtWidgets import (
QFrame,
QVBoxLayout,
QTabWidget,
QGridLayout,
QPushButton,
QWidget,
)
from aaredaqlib.diffraction_geometry import DiffractionGeometry
from aaredaqlib.models import DAQStatusModel
from aaredaqlib.rotation_scan import RotationScanRequest
from aaredaqlib.sample_geometry import SampleGeometryModel
from aare.common.diffraction_geometry import DiffractionGeometry
from aare.common.models import DAQStatusModel
from aare.common.sample_geometry import SampleGeometryModel
from aaregui.panels.file_path_panel import FilePathPanel
from aaregui.panels.fluorescence_data_collection import FluorescenceDataCollectionPanel
from aaregui.panels.raster_data_collection import RasterDataCollectionPanel
from aaregui.panels.rotation_data_collection import RotationDataCollectionPanel
from aaregui.panels.smart_rotation_panel import SimpleRotationSettingsPanel
from aaregui.scan_logic.raster_grid_manager import RasterGridManager
from aaregui.widgets.title_label import TitleLabel
from aare.gui.panels.file_path_panel import FilePathPanel
from aare.gui.panels.fluorescence_data_collection import FluorescenceDataCollectionPanel
from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel
from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel
from aare.gui.panels.smart_rotation_panel import SimpleRotationSettingsPanel
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
class DataCollectionSettings(QFrame):
@@ -0,0 +1,494 @@
from __future__ import annotations
import json
import logging
from typing import Dict
from PySide6.QtCore import Qt, Slot, QUrl
from PySide6.QtGui import QGuiApplication, QDesktopServices
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLineEdit,
QPushButton,
QTabWidget,
QTableWidget,
QTableWidgetItem,
QTextEdit,
QLabel,
QWidget,
QCheckBox,
QDialogButtonBox,
QFormLayout,
QFrame,
)
from aare.common.error_codes import error_code_help
from aare.common.logger_config import QtLogEmitter, QtLogHandler, find_existing_formatter, attach_to_logger
from aare.gui.threads.daq_worker import DAQWorker
class DeveloperHelpDialog(QDialog):
def __init__(self, *, daq: DAQWorker, is_staff: bool, parent=None):
super().__init__(parent)
self._daq = daq
self._is_staff = bool(is_staff)
self._codes: Dict[str, str] = {}
self._last_payload: dict = {}
self._freeze_payload: bool = False
self._always_highlight_last_error: bool = True
self.setWindowTitle("Developer / Help")
self.setMinimumSize(860, 560)
root = QVBoxLayout(self)
root.setContentsMargins(12, 12, 12, 12)
root.setSpacing(8)
# Compact banner (staff only)
self._banner = QLabel(self)
self._banner.setVisible(self._is_staff)
self._banner.setWordWrap(True)
self._banner.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._banner.setStyleSheet(
"QLabel {"
" background: #f6f6f6;"
" border: 1px solid #d0d0d0;"
" border-radius: 6px;"
" padding: 6px 8px;"
"}"
)
root.addWidget(self._banner)
# Top controls
top = QHBoxLayout()
top.setSpacing(8)
root.addLayout(top)
top.addWidget(QLabel("Filter:", self))
self._filter = QLineEdit(self)
self._filter.setPlaceholderText("Type to filter (matches name, value, or help)…")
self._filter.setClearButtonEnabled(True)
self._filter.setMinimumHeight(28)
self._filter.setStyleSheet(
"QLineEdit {"
" background: white;"
" border: 1px solid #bdbdbd;"
" border-radius: 6px;"
" padding: 4px 8px;"
"}"
)
self._filter.textChanged.connect(self._apply_filter)
top.addWidget(self._filter, 1)
self._refresh_btn = QPushButton("Refresh", self)
self._refresh_btn.clicked.connect(self.refresh)
top.addWidget(self._refresh_btn)
self._freeze_cb = QCheckBox("Freeze payload", self)
self._freeze_cb.setVisible(self._is_staff)
self._freeze_cb.toggled.connect(self._set_freeze_payload)
top.addWidget(self._freeze_cb)
self._highlight_cb = QCheckBox("Always highlight last error code", self)
self._highlight_cb.setVisible(self._is_staff)
self._highlight_cb.setChecked(True)
self._highlight_cb.toggled.connect(self._set_always_highlight)
top.addWidget(self._highlight_cb)
self._copy_selected_btn = QPushButton("Copy code", self)
self._copy_selected_btn.clicked.connect(self._copy_selected_code)
top.addWidget(self._copy_selected_btn)
self._copy_all_btn = QPushButton("Copy all (filtered)", self)
self._copy_all_btn.clicked.connect(self._copy_all_filtered)
top.addWidget(self._copy_all_btn)
self._copy_payload_btn = QPushButton("Copy payload", self)
self._copy_payload_btn.setVisible(self._is_staff)
self._copy_payload_btn.clicked.connect(self._copy_payload)
top.addWidget(self._copy_payload_btn)
# Staff utilities: open log files
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"))
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"))
top.addWidget(self._open_daq_log_btn)
# Tabs
self._tabs = QTabWidget(self)
root.addWidget(self._tabs, 1)
# Tab: error codes + details pane
self._codes_table = QTableWidget(self)
self._codes_table.setColumnCount(1)
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.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self._codes_table.itemSelectionChanged.connect(self._update_code_details)
self._codes_table.horizontalHeader().setStretchLastSection(True)
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;"
"}"
)
details_layout = QVBoxLayout(self._details_frame)
details_layout.setContentsMargins(10, 10, 10, 10)
details_layout.setSpacing(8)
form = QFormLayout()
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
details_layout.addLayout(form)
self._detail_name = QLabel("-", self)
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._copy_value_btn = QPushButton("Copy value", self)
self._copy_value_btn.clicked.connect(self._copy_selected_value)
value_row.addWidget(self._detail_value, 1)
value_row.addWidget(self._copy_value_btn, 0)
value_row_widget = QWidget(self)
value_row_widget.setLayout(value_row)
form.addRow("Value:", value_row_widget)
self._detail_help = QLabel("-", self)
self._detail_help.setWordWrap(True)
self._detail_help.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._detail_help.setStyleSheet(
"QLabel {"
" background: white;"
" border: 1px solid #e0e0e0;"
" border-radius: 6px;"
" padding: 8px;"
"}"
)
details_layout.addWidget(QLabel("Help:", self))
details_layout.addWidget(self._detail_help, 1)
codes_container = QWidget(self)
codes_layout = QVBoxLayout(codes_container)
codes_layout.setContentsMargins(0, 0, 0, 0)
codes_layout.setSpacing(8)
codes_layout.addWidget(self._codes_table, 1)
codes_layout.addWidget(self._details_frame, 0)
self._tabs.addTab(codes_container, "Error codes")
# Tab: last error payload (staff only) + summary header
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_text = QTextEdit(self)
self._payload_text.setReadOnly(True)
self._payload_text.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
if self._is_staff:
payload_container = QWidget(self)
payload_layout = QVBoxLayout(payload_container)
payload_layout.setContentsMargins(0, 0, 0, 0)
payload_layout.addWidget(self._payload_summary, 0)
payload_layout.addWidget(self._payload_text, 1)
self._tabs.addTab(payload_container, "Last error payload")
else:
self._payload_text.setPlainText("Hidden (staff only).")
# Tab: recent payloads (staff only)
self._payloads_text = QTextEdit(self)
self._payloads_text.setReadOnly(True)
self._payloads_text.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
if self._is_staff:
payloads_container = QWidget(self)
payloads_layout = QVBoxLayout(payloads_container)
payloads_layout.setContentsMargins(0, 0, 0, 0)
payloads_layout.addWidget(self._payloads_text, 1)
self._tabs.addTab(payloads_container, "Recent payloads")
# Tab: tracebacks (staff only)
self._tracebacks_text = QTextEdit(self)
self._tracebacks_text.setReadOnly(True)
self._tracebacks_text.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
self._tracebacks_text.setPlainText("Tracebacks will appear here (last 10).")
if self._is_staff:
tb_container = QWidget(self)
tb_layout = QVBoxLayout(tb_container)
tb_layout.setContentsMargins(0, 0, 0, 0)
tb_layout.addWidget(self._tracebacks_text, 1)
self._tabs.addTab(tb_container, "Tracebacks")
# Tab: error log (staff only) live view from python logging
self._error_log_text = QTextEdit(self)
self._error_log_text.setReadOnly(True)
self._error_log_text.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
if self._is_staff:
log_container = QWidget(self)
log_layout = QVBoxLayout(log_container)
log_layout.setContentsMargins(0, 0, 0, 0)
log_layout.addWidget(self._error_log_text, 1)
self._tabs.addTab(log_container, "Error log")
# Bottom button box
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
root.addWidget(buttons)
# Wire signals
self._daq.error_codes_loaded.connect(self.set_error_codes)
self._daq.last_error_payload_changed.connect(self.set_last_error_payload)
self._daq.last_error_payloads_changed.connect(self.set_last_error_payloads)
# Hook a Qt logging handler to show ERROR+ messages in the dialog
if self._is_staff:
self._qt_log_emitter = QtLogEmitter()
self._qt_log_emitter.message.connect(self._append_error_log_line)
self._qt_log_handler = QtLogHandler(self._qt_log_emitter)
self._qt_log_handler.setLevel(logging.ERROR)
self._qt_log_handler.setFormatter(find_existing_formatter())
attach_to_logger("aareGUI", self._qt_log_handler)
attach_to_logger("aareDAQ", self._qt_log_handler)
self._update_banner()
self._update_code_details()
def _open_log_file_for_logger(self, logger_name: str) -> None:
"""
Opens the first FileHandler path attached to the given logger name.
"""
log = logging.getLogger(logger_name)
paths: list[str] = []
for h in getattr(log, "handlers", []) or []:
p = getattr(h, "baseFilename", None)
if p and isinstance(p, str):
paths.append(p)
if not paths:
self._banner.setText(f"No file handler found for logger '{logger_name}'.")
return
QDesktopServices.openUrl(QUrl.fromLocalFile(paths[0]))
@Slot(bool)
def _set_freeze_payload(self, enabled: bool) -> None:
self._freeze_payload = bool(enabled)
@Slot(bool)
def _set_always_highlight(self, enabled: bool) -> None:
self._always_highlight_last_error = bool(enabled)
@Slot()
def refresh(self) -> None:
self._daq.get_error_codes()
self.set_last_error_payload(self._daq.get_last_error_payload())
if self._is_staff:
self.set_last_error_payloads(self._daq.get_last_error_payloads())
@Slot(dict)
def set_error_codes(self, codes: dict) -> None:
self._codes = {str(k): str(v) for k, v in (codes or {}).items()}
self._apply_filter()
@Slot(dict)
def set_last_error_payload(self, payload: dict) -> None:
if not self._is_staff:
return
if self._freeze_payload:
return
self._last_payload = payload or {}
pretty = json.dumps(self._last_payload, indent=2, sort_keys=True, default=str)
self._payload_text.setPlainText(pretty)
code, msg = self._extract_code_message()
url = str((self._last_payload or {}).get("url") or "")
status = (self._last_payload or {}).get("http_status")
self._payload_summary.setText(
f"URL: {url}\nHTTP: {status}\nCode: {code or '-'}\nMessage: {msg or '-'}"
)
self._update_banner()
self._select_code_from_last_error()
@Slot(list)
def set_last_error_payloads(self, payloads: list) -> None:
if not self._is_staff:
return
blocks: list[str] = []
for i, p in enumerate(payloads[-10:], start=max(1, len(payloads) - 9)):
try:
pretty = json.dumps(p or {}, indent=2, sort_keys=True, default=str)
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)")
@Slot(str)
def _append_error_log_line(self, line: str) -> None:
if not self._is_staff:
return
self._error_log_text.append(line)
def _code_name_for_value(self, value: str) -> str | None:
for k, v in self._codes.items():
if v == value:
return k
return None
def _extract_code_message(self) -> tuple[str | None, str | None]:
body = (self._last_payload or {}).get("body_json")
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 (None, None)
def _update_banner(self) -> None:
if not self._is_staff:
return
code, msg = self._extract_code_message()
if code or msg:
parts = []
if code:
parts.append(f"Last error: {code}")
if msg:
parts.append(msg)
self._banner.setText("".join(parts))
else:
self._banner.setText("Last error: (none captured yet)")
def _code_name_for_value(self, value: str) -> str | None:
for name, v in (self._codes or {}).items():
if str(v) == str(value):
return str(name)
return None
def _select_code_by_name(self, name: str) -> None:
if not name:
return
for row in range(self._codes_table.rowCount()):
item = self._codes_table.item(row, 0)
if item and item.text() == name:
self._codes_table.setCurrentCell(row, 0)
self._codes_table.scrollToItem(item)
return
def _select_code_from_last_error(self) -> None:
if not self._codes:
return
code_value, _ = self._extract_code_message()
if not code_value:
return
name = self._code_name_for_value(code_value)
if name:
self._select_code_by_name(name)
@Slot()
def _apply_filter(self) -> None:
term = (self._filter.text() or "").strip().lower()
items = sorted(self._codes.items(), key=lambda kv: kv[0])
if term:
def _match(name: str, value: str) -> bool:
h = error_code_help(value) or ""
return term in f"{name}\n{value}\n{h}".lower()
items = [(k, v) for (k, v) in items if _match(k, v)]
self._codes_table.setRowCount(len(items))
for row, (k, v) in enumerate(items):
item = QTableWidgetItem(k)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self._codes_table.setItem(row, 0, item)
self._codes_table.resizeColumnsToContents()
# Keep selection sensible (dont resize columns here; avoids jitter)
if self._codes_table.rowCount() > 0 and self._codes_table.currentRow() < 0:
self._codes_table.setCurrentCell(0, 0)
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:
self._select_code_from_last_error()
def _selected_code(self) -> tuple[str | None, str | None]:
row = self._codes_table.currentRow()
if row < 0:
return (None, None)
name_item = self._codes_table.item(row, 0)
if not name_item:
return (None, None)
name = name_item.text()
value = self._codes.get(name)
return (name, value)
@Slot()
def _update_code_details(self) -> None:
name, value = self._selected_code()
if not name or not value:
self._detail_name.setText("-")
self._detail_value.setText("-")
self._detail_help.setText("Select an error code to see details.")
self._copy_value_btn.setEnabled(False)
return
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._copy_value_btn.setEnabled(True)
@Slot()
def _copy_selected_code(self) -> None:
name, value = self._selected_code()
if not name or not value:
QGuiApplication.clipboard().setText("")
return
QGuiApplication.clipboard().setText(f"{name}={value}")
@Slot()
def _copy_selected_value(self) -> None:
_, value = self._selected_code()
QGuiApplication.clipboard().setText(value or "")
@Slot()
def _copy_all_filtered(self) -> None:
rows = self._codes_table.rowCount()
out = {}
for r in range(rows):
name = self._codes_table.item(r, 0).text()
out[name] = self._codes.get(name, "")
QGuiApplication.clipboard().setText(json.dumps(out, indent=2, sort_keys=True))
@Slot()
def _copy_payload(self) -> None:
if not self._is_staff:
return
QGuiApplication.clipboard().setText(self._payload_text.toPlainText())
@@ -1,18 +1,17 @@
# Python
from PySide6.QtCore import Slot, Qt, Signal
from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QFrame, QPushButton, QSpacerItem, QSizePolicy, QVBoxLayout, \
QHBoxLayout
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QPushButton, QVBoxLayout
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import numpy as np
from aaregui.widgets.number_line_edit import NumberLineEdit
from aaregui.widgets.title_label import TitleLabel
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
from aaredaqlib.logger_config import setup_logger
from aare.common.logger_config import setup_logger
logger = setup_logger("aareGUI")
class FaceDetectionPanel(QWidget):
face_detection = Signal(int, int)
@@ -21,6 +20,7 @@ class FaceDetectionPanel(QWidget):
super().__init__(parent)
self.steps = 14
self.step_size = 15
self._manual_run_requested = False
def _set_steps(val: float):
self.steps = int(val)
@@ -28,29 +28,31 @@ class FaceDetectionPanel(QWidget):
def _set_step_size(val: float):
self.step_size = int(val)
self.fig = Figure(figsize=(5, 4), tight_layout=True)
self.fig = Figure(figsize=(5, 4))
self.fig.subplots_adjust(
left=0.12,
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) # Height vs angle
self.ax2 = self.fig.add_subplot(2, 1, 2) # Area vs angle
self.ax1 = self.fig.add_subplot(2, 1, 1)
self.ax2 = self.fig.add_subplot(2, 1, 2)
self._top_layout = QGridLayout()
self._top_layout.addWidget(TitleLabel("TELL sample changer", self), 0, 0, 1, 3)
self.status_lbl = QLabel("")
self.status_lbl = QLabel("Idle")
self._top_layout.addWidget(self.status_lbl, 0, 2)
self._top_layout.addWidget(QLabel("step size", parent=self), 1, 0)
self.step_size_enter = NumberLineEdit(
0, 50, 15, decimals=4, parent=self
)
self.step_size_enter = NumberLineEdit(0, 50, 15, decimals=4, parent=self)
self._top_layout.addWidget(self.step_size_enter, 1, 1, 1, 3)
self.step_size_enter.newValue.connect(_set_step_size)
self._top_layout.addWidget(QLabel("°", parent=self), 1, 4)
self._top_layout.addWidget(QLabel("number of steps", parent=self), 2, 0)
self.steps_enter = NumberLineEdit(
0, 50, 14, decimals=4, parent=self
)
self.steps_enter = NumberLineEdit(0, 50, 14, decimals=4, parent=self)
self._top_layout.addWidget(self.steps_enter, 2, 1, 1, 3)
self._top_layout.addWidget(QLabel("", parent=self), 2, 4)
self.steps_enter.newValue.connect(_set_steps)
@@ -70,49 +72,68 @@ class FaceDetectionPanel(QWidget):
def run_and_refresh(self):
try:
self._manual_run_requested = True
self.status_lbl.setText("Starting...")
self.face_detection_button.setEnabled(False)
self.face_detection.emit(int(self.steps), int(self.step_size))
except Exception as e:
self._manual_run_requested = False
self.status_lbl.setText(f"Error: {e}")
self.face_detection_button.setEnabled(True)
def update_plot(self, data):
samples = data.get("samples", [])
print(samples)
samples = data.get("samples", []) or []
running = bool(data.get("running", False))
angle = data.get("current_angle_deg")
status = data.get("status", "")
if running:
if self._manual_run_requested:
self.status_lbl.setText(f"Running... angle {angle}" if angle is not None else "Running...")
else:
self.status_lbl.setText(f"Automation running... angle {angle}" if angle is not None else "Automation running...")
else:
if self._manual_run_requested:
self.status_lbl.setText("Done")
self.face_detection_button.setEnabled(True)
self._manual_run_requested = False
elif samples:
self.status_lbl.setText("Showing latest result")
else:
self.status_lbl.setText("Idle")
self.ax1.clear()
self.ax2.clear()
if not samples:
self.ax1.clear()
self.ax2.clear()
self.ax1.text(0.5, 0.5, "No data", ha="center", va="center")
self.ax2.text(0.5, 0.5, "No data", ha="center", va="center")
self.canvas.draw_idle()
logger.info("No data")
return
angles = np.array([s["angle_deg"] for s in samples], dtype=float)
heights = np.array([s["height"] for s in samples], dtype=float)
areas = np.array([s["area"] for s in samples], dtype=float)
self.ax1.clear()
self.ax2.clear()
self.ax1.scatter(angles, heights, s=16, c="tab:blue", label="Height")
self.ax2.scatter(angles, areas, s=16, c="tab:green", label="Area")
ang_grid = np.linspace(angles.min(),angles.max(), 400)
#ang_grid_wrapped = ((ang_grid + 180) % 360) - 180
hf = data.get("height_fit", {})
ang_grid = np.linspace(angles.min(), angles.max(), 400)
hf = data.get("height_fit", {}) or {}
if {"A", "B", "phi_rad", "C"} <= hf.keys():
A, B, phi, C = hf["A"], hf["B"], hf["phi_rad"], hf["C"]
height_fit = A + B * np.cos(C*np.deg2rad(ang_grid) - phi)
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:
logger.info(f"best angle: {hf['best_angle_deg']}")
self.ax1.axvline(hf["best_angle_deg"], color="tab:orange", ls="--", alpha=0.6)
af = data.get("area_fit", {})
if {"A", "B", "phi_rad", "C"} <= hf.keys():
af = data.get("area_fit", {}) or {}
if {"A", "B", "phi_rad", "C"} <= af.keys():
A2, B2, phi2, C2 = af["A"], af["B"], af["phi_rad"], af["C"]
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:
logger.info(f"best angle: {af['best_angle_deg']}")
self.ax2.axvline(af["best_angle_deg"], color="tab:red", ls="--", alpha=0.6)
self.ax1.set_xlabel("Angle (deg)")
@@ -1,13 +1,12 @@
import copy
import os
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import Signal, Slot, Qt
from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QLineEdit, QSpinBox, QCheckBox, QMessageBox
from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QLineEdit, QSpinBox, QMessageBox
from aaredaqlib.models import SampleShortInfo, DAQStatusModel
from aaregui.widgets.title_label import TitleLabel
from aare.common.models import SampleShortInfo, DAQStatusModel
from aare.gui.widgets.title_label import TitleLabel
## Logic for filenames:
## 1. For rasters 'raster/' subfolder is added at the top level of the path (managed by DAQ) - e.g. raster/20250101/PX-456/01/dataset
@@ -183,6 +182,7 @@ class FilePathPanel(QWidget):
self.__puck_pos = 99
self.directory_edit.setText(f"{self.__formatted_date}/test")
else:
self.__sample_id = sample.db_id
self.__sample_name = sample.sample_name
self.__dewar_pos = sample.loc_str()
self.__puck_name = sample.puck_name
@@ -1,8 +1,8 @@
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QPushButton, QCheckBox
from aaredaqlib.models import FluorescenceSpectrumParameterModel
from aaregui.widgets.number_line_edit import NumberLineEdit
from aare.common.models import FluorescenceSpectrumParameterModel
from aare.gui.widgets.number_line_edit import NumberLineEdit
class FluorescenceDataCollectionPanel(QWidget):
@@ -1,11 +1,11 @@
import numpy as np
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis
from PySide6.QtCore import QPointF, Qt, Slot, Signal, QEvent
from PySide6.QtCore import QPointF, Qt, Slot, QEvent
from PySide6.QtGui import QPainter, QColor, QPen
from PySide6.QtWidgets import QWidget, QGridLayout, QGraphicsSimpleTextItem, QLabel
from aaredaqlib.logger_config import setup_logger
from aaredaqlib.models import FluorescenceSpectrumOutputModel, DAQStatusModel
from aare.common.logger_config import setup_logger
from aare.common.models import FluorescenceSpectrumOutputModel, DAQStatusModel
logger = setup_logger("aareGUI")
+57
View File
@@ -0,0 +1,57 @@
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import QWidget, QGridLayout, QSlider, QLabel
from aare.common.models import DAQStatusModel
from aare.gui.widgets.title_label import TitleLabel
class IlluminationPanel(QWidget):
front_light = Signal(int)
back_light = Signal(int)
def __init__(self, parent=None):
super().__init__(parent)
grid_layout = QGridLayout(self)
grid_layout.addWidget(TitleLabel("Light", self), 0, 0, 1, 2)
front_label = QLabel("Front light", parent=self)
front_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
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.setRange(0, 100)
self.front_light_slider.sliderPressed.connect(self.on_slider_pressed)
self.front_light_slider.sliderReleased.connect(self.on_front_slider_released)
grid_layout.addWidget(self.front_light_slider, 2, 0, 1, 2)
back_label = QLabel("Back light", parent=self)
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.setRange(0, 100)
self.back_light_slider.sliderPressed.connect(self.on_slider_pressed)
self.back_light_slider.sliderReleased.connect(self.on_back_slider_released)
grid_layout.addWidget(self.back_light_slider, 4, 0, 1, 2)
@Slot()
def on_slider_pressed(self):
self.is_sliding = True
@Slot()
def on_front_slider_released(self):
self.is_sliding = False
self.front_light.emit(self.front_light_slider.value())
@Slot()
def on_back_slider_released(self):
self.is_sliding = False
self.back_light.emit(self.back_light_slider.value())
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
if not self.is_sliding: # Update only if not sliding
self.front_light_slider.setValue(round(s.bl.front_light))
self.back_light_slider.setValue(round(s.bl.back_light))
@@ -1,6 +1,6 @@
from PySide6.QtWidgets import QWidget, QGridLayout, QPushButton
from aaregui.widgets.title_label import TitleLabel
from aare.gui.widgets.title_label import TitleLabel
class LoopCenteringPanel(QWidget):
@@ -1,10 +1,10 @@
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QTextEdit, QPushButton, QCheckBox, QLineEdit
from aareDBclient import DataCollectionParameters
from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QPushButton, QCheckBox, QLineEdit
from aareDB import DataCollectionParameters
from aaredaqlib.models import SampleShortInfo, DAQStatusModel
from aaregui.widgets.number_line_edit import NumberLineEdit
from aaregui.widgets.title_label import TitleLabel
from aare.common.models import SampleShortInfo, DAQStatusModel
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
class ManualSamplePanel(QWidget):
@@ -1,10 +1,10 @@
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QHBoxLayout
from aaredaqlib.models import DAQStatusModel
from aare.common.models import DAQStatusModel
from aaregui.widgets.button_with_payload import ButtonWithPayload
from aaregui.widgets.number_line_edit import NumberLineEdit
from aaregui.widgets.title_label import TitleLabel
from aare.gui.widgets.button_with_payload import ButtonWithPayload
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
class OmegaEntryWidget(QWidget):
@@ -21,6 +21,7 @@ class OmegaEntryWidget(QWidget):
class OmegaPanel(QWidget):
set_omega = Signal(float)
set_omega_rel = Signal(float)
def __init__(self, parent=None):
super().__init__(parent)
@@ -57,7 +58,7 @@ class OmegaPanel(QWidget):
@Slot(dict)
def omega_button_pressed(self, payload: dict):
if "rel" in payload:
self.set_omega.emit(self.__omega + payload["rel"])
self.set_omega_rel.emit(payload["rel"])
elif "abs" in payload:
self.set_omega.emit(payload["abs"])
@@ -1,14 +1,13 @@
from PySide6.QtCore import Signal, Slot, Qt
from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QSizePolicy, QSpacerItem, QPushButton, QHBoxLayout, \
QComboBox, QSlider, QMessageBox
from PySide6.QtWidgets import QLabel, QSizePolicy, QSpacerItem, QPushButton, QComboBox, QSlider, QMessageBox
from aaredaqlib.diffraction_geometry import DiffractionGeometry
from aaredaqlib.models import DAQStatusModel, BeamlineStateEnum
from aaregui.panels.scan_settings_panel import ScanSettingsPanel
from aaregui.scan_logic.raster_grid_manager import RasterGridManager, RasterGridMetric
from aaregui.widgets.number_line_edit import NumberLineEdit, CheckedLineEdit
from aaregui.widgets.raster_grid_table import RasterGridTable
from aaredaqlib.logger_config import setup_logger
from aare.common.diffraction_geometry import DiffractionGeometry
from aare.common.models import DAQStatusModel, BeamlineStateEnum
from aare.gui.panels.scan_settings_panel import ScanSettingsPanel
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager, RasterGridMetric
from aare.gui.widgets.number_line_edit import CheckedLineEdit
from aare.gui.widgets.raster_grid_table import RasterGridTable
from aare.common.logger_config import setup_logger
logger = setup_logger("aareGUI")
@@ -1,7 +1,7 @@
# reference_tools_panel.py
from typing import Callable, List, Dict, Optional
from typing import Optional
from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex, QTimer, Slot, Signal
from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex, Slot, Signal
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import (
QFrame,
@@ -14,9 +14,9 @@ from PySide6.QtWidgets import (
QAbstractItemView,
)
from aaredaqlib.logger_config import setup_logger
from aaredaqlib.models import SampleShortInfoList, SampleShortInfo, BeamlineStateEnum, DAQStatusModel
from aaregui.widgets.title_label import TitleLabel
from aare.common.logger_config import setup_logger
from aare.common.models import SampleShortInfoList, SampleShortInfo, DAQStatusModel
from aare.gui.widgets.title_label import TitleLabel
logger = setup_logger("aareGUI")

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