Feat/release workflow #114

Merged
perl_d merged 7 commits from feat/release_workflow into main 2026-07-27 15:04:19 +02:00
43 changed files with 385 additions and 358 deletions
+46
View File
@@ -0,0 +1,46 @@
name: "Install"
description: "Setup system and python environment and install repo"
inputs:
python_version:
required: false
default: "3.12"
description: "Python version to use"
index_username:
required: false
default: ""
description: "Username for the private psi package index"
index_password:
required: false
default: ""
description: "Token/password for the private psi package index"
runs:
using: "composite"
steps:
- name: Install Libraries
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y \
libgl1 \
libegl1 \
libxkbcommon-x11-0 \
libdbus-1-3 \
libxcb-cursor0 \
libx11-xcb1
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: ${{ inputs.python_version }}
- name: Install repo
shell: bash
env:
UV_INDEX_PSI_USERNAME: ${{ inputs.index_username }}
UV_INDEX_PSI_PASSWORD: ${{ inputs.index_password }}
run: |
pip install uv
uv venv
source .venv/bin/activate
uv pip install -e .[test]
-223
View File
@@ -1,223 +0,0 @@
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
PYTHONPATH: src
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set Up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-test-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-test-
- name: Create virtual environment
run: |
python -m venv venv
- name: Install dependencies
env:
PIP_INDEX_URL: https://${{ secrets.GITEA_USER }}:${{ secrets.GITEA_TOKEN }}@gitea.psi.ch/api/packages/mx/pypi/simple/
PIP_EXTRA_INDEX_URL: https://pypi.org/simple
run: |
source venv/bin/activate
python -m pip install --upgrade pip
pip install pytest pytest-cov pytest-mock pytest-qt pytest-asyncio
pip install -e .
- name: Install system dependencies for PySide6
run: |
sudo apt-get update
sudo apt-get install -y \
libgl1 \
libegl1 \
libxkbcommon-x11-0 \
libdbus-1-3 \
libxcb-cursor0 \
libx11-xcb1
- name: Debug Python env
run: |
source venv/bin/activate
which python
which pytest
python -m pip list | grep pytest
- name: Run tests with pytest
env:
QT_QPA_PLATFORM: offscreen
BEAMLINE: SIMULATED
run: |
source venv/bin/activate
python -m pytest --cov=aare --cov-report=xml tests/unit
build:
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
- name: Set Up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Set Up Python Environment
run: |
python3 -m venv venv # Create a virtual environment
source venv/bin/activate
pip install twine build
- name: Build the package
run: |
source venv/bin/activate
python -m build
- name: Upload Package to Gitea PyPI
if: startsWith(github.ref, 'refs/tags/')
env:
TWINE_USERNAME: "__token__" # Username for Twine when using token-based auth
TWINE_PASSWORD: ${{ secrets.PIP_REPOSITORY_API_TOKEN }} # Use the secret for authentication
run: |
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, test]
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: docs
run: |
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
pip install -e .. --no-deps
sphinx-apidoc -o modules/ ../src/aare --separate --module-first --force --remove-old
sphinx-build -b html . _build/html
- 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="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 docs/_build:"
ls -la 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
+51
View File
@@ -0,0 +1,51 @@
name: CI
on:
push:
pull_request:
workflow_dispatch:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup
uses: ./.gitea/actions/install
with:
python_version: "3.12"
index_username: ${{ secrets.GITEA_USER }}
index_password: ${{ secrets.GITEA_TOKEN }}
- name: Format
run: |
source .venv/bin/activate
ruff format --check
test:
runs-on: ubuntu-latest
needs: "lint"
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup
uses: ./.gitea/actions/install
with:
python_version: ${{ matrix.python-version }}
index_username: ${{ secrets.GITEA_USER }}
index_password: ${{ secrets.GITEA_TOKEN }}
- name: Run Pytest with Coverage
env:
QT_QPA_PLATFORM: offscreen
BEAMLINE: SIMULATED
run: |
source .venv/bin/activate
pytest --cov=aare --cov-config=./pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail ./tests/unit
+77
View File
@@ -0,0 +1,77 @@
name: Build and Publish
on:
push:
branches:
- main
jobs:
release:
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-release-${{ github.ref_name }}
cancel-in-progress: false
outputs:
release_made: ${{ steps.release_step.outputs.release_made }}
permissions:
contents: write
steps:
- name: Setup | Checkout Repository on Release Branch
uses: actions/checkout@v6
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Setup | Force release branch to be at workflow sha
run: |
git reset --hard ${{ github.sha }}
- name: Evaluate | Verify upstream has NOT changed
shell: bash
run: |
set +o pipefail
UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | cut -d' ' -f2 | grep -E '\.{3}' | cut -d'.' -f4)"
printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME"
set -o pipefail
if [ -z "$UPSTREAM_BRANCH_NAME" ]; then
printf >&2 '%s\n' "::error::Unable to determine upstream branch name!"
exit 1
fi
git fetch "${UPSTREAM_BRANCH_NAME%%/*}"
if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then
printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!"
exit 1
fi
HEAD_SHA="$(git rev-parse HEAD)"
if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then
printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]"
printf >&2 '%s\n' "::error::Upstream has changed, aborting release..."
exit 1
fi
printf '%s\n' "Verified upstream branch has not changed, continuing with release..."
- name: Semantic Version Release
id: release_step
env:
TWINE_USERNAME: "__token__"
TWINE_PASSWORD: ${{ secrets.MX_GITEA_BOT_TOKEN }}
MX_GITEA_BOT_TOKEN: ${{ secrets.MX_GITEA_BOT_TOKEN }}
run: |
pip install python-semantic-release==9.* wheel build twine
semantic-release --config ./ci/semantic_release.toml version
if [ ! -d dist ]; then echo No release will be made; echo "release_made=false" >> "$GITHUB_OUTPUT"; exit 0; fi
echo "release_made=true" >> "$GITHUB_OUTPUT"
twine upload dist/* --repository-url https://gitea.psi.ch/api/packages/mx/pypi
semantic-release publish
+3
View File
@@ -1,4 +1,7 @@
# Changelog
## Hand-written changelog is now superseded by the generated one in CHANGELOG.md; preserved for posterity.
## 0.3.2 (unreleased)
- **Authentication & Sessions**:
- Migrated DAQ server URLs to HTTPS with per-beamline configurable certificate paths (cacert) and mTLS; GUI now validates certificates and handles self-signed certs / SSL errors in `DAQWorker` and `SSEClient`.
-1
View File
@@ -1 +0,0 @@
0.3.2
+35
View File
@@ -0,0 +1,35 @@
[tool.semantic_release]
build_command = "python -m build"
version_toml = [
"./pyproject.toml:project.version",
]
[tool.semantic_release.commit_author]
env = "GIT_COMMIT_AUTHOR"
default = "semantic-release <semantic-release>"
[tool.semantic_release.commit_parser_options]
allowed_tags = [
"build",
"chore",
"ci",
"docs",
"feat",
"fix",
"perf",
"style",
"refactor",
"test",
]
minor_tags = ["feat"]
patch_tags = ["fix", "perf"]
default_bump_level = 0
[tool.semantic_release.remote]
type = "gitea"
domain = "gitea.psi.ch"
token = { env = "MX_GITEA_BOT_TOKEN" }
[tool.semantic_release.publish]
dist_glob_patterns = ["dist/*"]
upload_to_vcs_release = true
+2 -1
View File
@@ -41,7 +41,8 @@ test = [
"pytest-mock==3.14.0",
"pytest-qt==4.4.0",
"pytest-asyncio==0.25.3",
"pytest-timeout"
"pytest-timeout",
"ruff==0.15.*"
]
[lint]
+1 -1
View File
@@ -20,7 +20,7 @@ def decode_bulk(value: Any):
def fetch_key(r: redis.Redis, key: bytes) -> Tuple[str, Any]:
key_s = decode_bulk(key)
decode_bulk(key)
t = r.type(key)
if isinstance(t, bytes):
t = t.decode()
+2 -2
View File
@@ -229,7 +229,7 @@ class AareWrapper:
experiment_params_payload = ExperimentParametersCreate(
type="standard", beamline_parameters=beamline_params, sample_id=s.db_id
)
response = self.__sample_api.create_experiment_parameters_for_sample(
self.__sample_api.create_experiment_parameters_for_sample(
sample_id=s.db_id, experiment_parameters_create=experiment_params_payload
)
# logger.debug("Experiment parameters created:", response)
@@ -291,7 +291,7 @@ class AareWrapper:
experiment_params_payload = ExperimentParametersCreate(
type="standard", beamline_parameters=beamline_params, sample_id=s.db_id
)
response = self.__sample_api.create_experiment_parameters_for_sample(
self.__sample_api.create_experiment_parameters_for_sample(
sample_id=s.db_id, experiment_parameters_create=experiment_params_payload
)
# logger.info("Experiment parameters created:", response)
+5 -6
View File
@@ -42,11 +42,8 @@ from aarecommon.models.models import (
)
from aare.daq.config_model import LocalContactConfigModel
#TODO WHAT SHOULD THIS BE? This should be in the YAMl file it is beamline specific
ABR_POS_MOUNT = AerotechCoordinate(
at_mm=Coordinate(x=0, y=0, z=0),
omega_deg=0
)
# TODO WHAT SHOULD THIS BE? This should be in the YAMl file it is beamline specific
ABR_POS_MOUNT = AerotechCoordinate(at_mm=Coordinate(x=0, y=0, z=0), omega_deg=0)
# TODO WHAT SHOULD THIS BE? This should be in the YAMl file it is beamline specific
@@ -1261,7 +1258,9 @@ class BeamlineConfig:
logger.warning(f"Failed to read Local Contact config from Redis: {e}")
return default
def set_local_contact_config(self, config: LocalContactConfigModel | dict) -> LocalContactConfigModel:
def set_local_contact_config(
self, config: LocalContactConfigModel | dict
) -> LocalContactConfigModel:
validated = LocalContactConfigModel.model_validate(config)
try:
redis_key = f"{self.__bl}:local_contact_config"
+33 -23
View File
@@ -1385,8 +1385,9 @@ class AareDAQ:
)
return None
def _execute_rotation_sequence(self, rotation_request: RotationScanRequest) -> CompletedRotationScan | None:
def _execute_rotation_sequence(
self, rotation_request: RotationScanRequest
) -> CompletedRotationScan | None:
"""
Execute rotation scan.
@@ -1574,19 +1575,21 @@ class AareDAQ:
self.last_time = end - start
def _expand_macros(self, name: str) -> str:
name = name.replace('{date}', datetime.now().strftime('%Y%m%d'))
name = name.replace('{sample}', self.sample.sample_name)
name = name.replace('{CrystalName}', self.sample.sample_name)
name = name.replace('{puck}', self.sample.puck_name)
name = name.replace('{position}', f"{self.sample.pin:02d}")
name = name.replace('{sample_id}', f"{self.sample.db_id}")
name = name.replace('{beamline}', f"{self._beamline.value.lower()}")
name = name.replace('{prefix}', f"{self.sample.puck_name}/{self.sample.pin:02d}/{self.sample.sample_name}")
#TODO work out why clean_filename is removing slahses
#name = clean_filename(name)
name = name.replace("{date}", datetime.now().strftime("%Y%m%d"))
name = name.replace("{sample}", self.sample.sample_name)
name = name.replace("{CrystalName}", self.sample.sample_name)
name = name.replace("{puck}", self.sample.puck_name)
name = name.replace("{position}", f"{self.sample.pin:02d}")
name = name.replace("{sample_id}", f"{self.sample.db_id}")
name = name.replace("{beamline}", f"{self._beamline.value.lower()}")
name = name.replace(
"{prefix}", f"{self.sample.puck_name}/{self.sample.pin:02d}/{self.sample.sample_name}"
)
# TODO work out why clean_filename is removing slahses
# name = clean_filename(name)
return name
def spreadsheet_params(self) -> tuple[Optional[SimpleScanParameters], str|None]:
def spreadsheet_params(self) -> tuple[Optional[SimpleScanParameters], str | None]:
file_prefix = None
if self.status.sample is None:
@@ -1979,7 +1982,7 @@ class AareDAQ:
self.save_screenshot_db(sample_id, screenshot_name)
if request.transmission is not None and request.transmission != self.__devs.transmission:
logger.info(f'requesting transmission to move to {request.transmission}')
logger.info(f"requesting transmission to move to {request.transmission}")
self.__devs.transmission = request.transmission
start_pos = getattr(request, "start", None)
@@ -2700,7 +2703,9 @@ class AareDAQ:
if raster_params.filename is not None:
logger.info(f"Using filename {raster_params.filename}")
sample_prefix = "{filename}/{prefix}".format(filename=raster_params.filename, prefix=sample.sample_name)
sample_prefix = "{filename}/{prefix}".format(
filename=raster_params.filename, prefix=sample.sample_name
)
raster_grid = RasterGridRequest(
exp_time_s=raster_params.exp_time_s,
@@ -2767,17 +2772,20 @@ class AareDAQ:
update_sample = self.sample
if (
update_sample is not None
and update_sample.db_id is not None
and update_sample.db_id == sample.db_id
update_sample is not None
and update_sample.db_id is not None
and update_sample.db_id == sample.db_id
):
logger.info(f"Updating sample info {sample.db_id} old run_number"
f" sample.run_number {sample.run_number} new run_number {update_sample.run_number}")
logger.info(
f"Updating sample info {sample.db_id} old run_number"
f" sample.run_number {sample.run_number} new run_number {update_sample.run_number}"
)
if params.filename is not None:
logger.info(f"Using filename {params.filename}")
sample_prefix = "{filename}/{prefix}".format(filename=params.filename, prefix=sample.sample_name)
sample_prefix = "{filename}/{prefix}".format(
filename=params.filename, prefix=sample.sample_name
)
rotation_request = RotationScanRequest(
start_omega_deg=start_omega,
@@ -3492,7 +3500,9 @@ class AareDAQ:
finally:
self.__cfg.state_busy = False
def fluorimeter_take_spectrum(self, fm: FluorescenceSpectrumParameterModel) -> FluorescenceSpectrumOutputModel:
def fluorimeter_take_spectrum(
self, fm: FluorescenceSpectrumParameterModel
) -> FluorescenceSpectrumOutputModel:
self.__cfg.try_set_busy(timeout=360)
try:
@@ -3,7 +3,6 @@ from dataclasses import dataclass
from math import ceil, floor
from typing import Callable
import cv2
from aarecommon.config.beamline import cfg_get
from aarecommon.config.logger_events import (
geom_log_context,
@@ -112,7 +111,6 @@ def get_ml_bounding_box(
preferred_class=(3, 0), return_image=True, return_bundle_meta=True
)
m = prediction_result.box
bundle_image = prediction_result.image
log_ml_bundle_meta(
logger,
@@ -53,7 +53,6 @@ class FaceDetectionService:
beam_y = geom.beam_location_pxl.y
beam_x = geom.beam_location_pxl.x
x1 = model.box.top_x
y1 = model.box.top_y
y2 = model.box.bottom_y
-2
View File
@@ -1,7 +1,6 @@
import asyncio
import hmac
import importlib
import io
import json
import os
import random
@@ -9,7 +8,6 @@ import time
from contextlib import asynccontextmanager
from typing import AsyncGenerator, Optional
import cv2
import uvicorn
from aarecommon.config.beamline import mx_beamline
from aarecommon.config.logger import get_uvicorn_logging_config, setup_logger
+3 -3
View File
@@ -256,7 +256,7 @@ class JFJochWrapper:
def detector(self) -> jfjoch_client.models.DetectorListElement:
try:
l = self.__api.config_select_detector_get()
detector_list = self.__api.config_select_detector_get()
except Exception as e:
self._raise_jfjoch_error(
f"JFJoch detector configuration retrieval failed: {e}",
@@ -265,7 +265,7 @@ class JFJochWrapper:
endpoint="config_select_detector_get",
)
if len(l.detectors) == 0:
if len(detector_list.detectors) == 0:
raise JFJochCommunicationError(
"JFJoch returned no configured detectors",
operation="GET",
@@ -275,7 +275,7 @@ class JFJochWrapper:
)
try:
return l.detectors[l.current_id]
return detector_list.detectors[detector_list.current_id]
except Exception as e:
self._raise_jfjoch_error(
"JFJoch returned an invalid selected detector entry",
-1
View File
@@ -220,7 +220,6 @@ class Smargon(object):
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):
+1 -5
View File
@@ -120,11 +120,7 @@ class PShellTellBackend:
if e.response is not None and e.response.text:
msg = e.response.text.strip()
raise TellCommunicationError(
msg,
base_url=self._url,
endpoint=endpoint,
operation=operation,
critical=True,
msg, base_url=self._url, endpoint=endpoint, operation=operation, critical=True
) from e
def eval(self, expr: str):
+4 -2
View File
@@ -359,7 +359,9 @@ class TellClient:
raise
except Exception as e:
logger.error(f"Exception occurred: {e}")
raise TellCommunicationError(message=f"Error during mount {segment}{puck}-{sample}: {e}", critical=True)
raise TellCommunicationError(
message=f"Error during mount {segment}{puck}-{sample}: {e}", critical=True
)
def unmount(self, force=False, wait=False, timeout=360.0):
if self.is_busy():
@@ -582,7 +584,7 @@ if __name__ == "__main__":
state = tell_client.get_robot_state()
manual_mode = tell_client.is_manual_mode()
print("state: ", state)
print("is manual mode True: ", manual_mode == True)
print("is manual mode True: ", manual_mode)
print(tell_client.backend.eval("is_manual_mode()&"))
print(tell_client.is_door_closed())
# print("release safety: ", tell_client.backend.eval('release_safety()&'))
+10 -4
View File
@@ -14,11 +14,17 @@ def wait_position(motor, target, tolerance=None, timeout=60.0):
elif isinstance(position, (bytes, str)):
tst = "'%s' == '%s'"
if isinstance(position, bytes):
ltst = lambda x, y, z: (
x.lower() == y.lower().encode() if isinstance(y, str) else x.lower() == y.lower()
)
def ltst(x, y, z):
return (
x.lower() == y.lower().encode()
if isinstance(y, str)
else x.lower() == y.lower()
)
else:
ltst = lambda x, y, z: x.lower() == y.lower()
def ltst(x, y, z):
return x.lower() == y.lower()
else:
raise RuntimeError("BUG: could not understand arguments: ")
+17 -21
View File
@@ -1,4 +1,3 @@
import importlib
import os
import sys
import traceback
@@ -20,24 +19,24 @@ logger = setup_logger(LOGGER_NAME)
def main():
"""Wrapped gui as main function to make tests easier"""
splash = None
try:
basedir = os.path.dirname(__file__)
icon_path = os.path.join(basedir, "graphics/aaregui_logo.svg")
banner_path = os.path.join(basedir, "graphics/aare_banner.png")
splash_pix = QtGui.QPixmap(banner_path)
splash = LoadingSplashScreen(splash_pix)
except Exception as e:
logger.error(f"Failed to load resources for splash screen: {e}")
sys.exit(1)
try:
# define application
app = QApplication(sys.argv)
app.setWindowIcon(QtGui.QIcon(icon_path))
app.setApplicationName("AareGUI")
app.setApplicationVersion("0.3.1")
app.setOrganizationName("PSI")
app.setOrganizationDomain("psi.ch")
# set icon
basedir = os.path.dirname(__file__)
icon_path = os.path.join(basedir, "graphics/aaregui_logo.svg")
app.setWindowIcon(QtGui.QIcon(icon_path))
# show splash screen
banner_path = os.path.join(basedir, "graphics/aare_banner.png")
splash_pix = QtGui.QPixmap(banner_path)
splash = LoadingSplashScreen(splash_pix)
splash.show()
splash.set_progress(10, "Initializing Application...")
@@ -200,16 +199,13 @@ def main():
logger.error(f"Error starting GUI: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
try:
QMessageBox.critical(
None,
"Fatal Error",
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
QMessageBox.critical(
None,
"Fatal Error",
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",
)
sys.exit(1)
+2 -2
View File
@@ -1852,7 +1852,7 @@ class MainWindow(QMainWindow):
logger.critical(f"Automation critical failure: {message}")
is_detector_failure = self._is_detector_critical_failure(message)
banner_message = (
(
self._detector_error_banner_text(automation=True, message=message)
if is_detector_failure
else f"Automation halted: {message}"
@@ -2493,6 +2493,6 @@ class MainWindow(QMainWindow):
"Idle timeout reached, but GUI remains open because beamline is busy or automation is active."
)
return
#TODO deny communication from GUI to DAQ while IDLE for too long rather than kill the GUI
# TODO deny communication from GUI to DAQ while IDLE for too long rather than kill the GUI
logger.warning("Closing GUI after inactivity timeout.")
self.close()
+2 -2
View File
@@ -90,9 +90,9 @@ class SampleQueueSpreadsheet(QAbstractTableModel):
row = parent.row()
try:
l = SampleShortInfoList.model_validate_json(data.text())
sample_data = SampleShortInfoList.model_validate_json(data.text())
self.beginResetModel()
for sample in l.s:
for sample in sample_data.s:
updated_row = row
updated_samples = []
for i in range(len(self.samples)):
+3 -3
View File
@@ -180,12 +180,12 @@ class UserSampleSpreadsheet(QAbstractTableModel):
def mimeData(self, indexes):
mime_data = QMimeData()
l = SampleShortInfoList(s=[])
sample_data = SampleShortInfoList(s=[])
for i in sorted(set(index.row() for index in indexes)):
l.s.append(self.__sorted_samples[i])
sample_data.s.append(self.__sorted_samples[i])
mime_data.setText(l.model_dump_json())
mime_data.setText(sample_data.model_dump_json())
return mime_data
def get_id(self, row: int) -> SampleShortInfo:
+1 -1
View File
@@ -78,7 +78,7 @@ class FaceDetectionPanel(QWidget):
samples = data.get("samples", []) or []
running = bool(data.get("running", False))
angle = data.get("current_angle_deg")
status = data.get("status", "")
data.get("status", "")
if running:
if self._manual_run_requested:
+10 -8
View File
@@ -86,14 +86,16 @@ class FilePathPanel(QWidget):
def _expand_macros(self, base: str, rn: int) -> str:
name = f"{base}_{rn:03d}"
name = name.replace('{date}', self.__formatted_date)
name = name.replace('{sample}', self.__sample_name)
name = name.replace('{CrystalName}', self.__sample_name)
name = name.replace('{puck}', self.__puck_name)
name = name.replace('{position}', f"{self.__puck_pos:02d}")
name = name.replace('{sample_id}', f"{self.__sample_id}")
name = name.replace('{beamline}', f"{self.__beamline}")
name = name.replace('{prefix}', f"{self.__puck_name}/{self.__puck_pos:02d}/{self.__sample_name}")
name = name.replace("{date}", self.__formatted_date)
name = name.replace("{sample}", self.__sample_name)
name = name.replace("{CrystalName}", self.__sample_name)
name = name.replace("{puck}", self.__puck_name)
name = name.replace("{position}", f"{self.__puck_pos:02d}")
name = name.replace("{sample_id}", f"{self.__sample_id}")
name = name.replace("{beamline}", f"{self.__beamline}")
name = name.replace(
"{prefix}", f"{self.__puck_name}/{self.__puck_pos:02d}/{self.__sample_name}"
)
return name
def _effective_dataset_base(self, base_no_run: str) -> str:
+15 -4
View File
@@ -22,7 +22,7 @@ from PySide6.QtWidgets import (
QTabWidget,
QTextEdit,
QVBoxLayout,
QWidget,
QWidget,
)
from aare.gui.constants import LOGGER_NAME
@@ -333,11 +333,22 @@ class LocalContactPanel(QFrame):
tools_layout.addWidget(tools)
tools_layout.addWidget(
self._make_button("Load BEC user macros", self._daq.bec_load_user_macros, "Loading BEC user macros."))
self._make_button(
"Load BEC user macros", self._daq.bec_load_user_macros, "Loading BEC user macros."
)
)
tools_layout.addWidget(
self._make_button("Show BEC user macros", self._daq.bec_list_all_user_macros, "Listing BEC user macros."))
self._make_button(
"Show BEC user macros",
self._daq.bec_list_all_user_macros,
"Listing BEC user macros.",
)
)
tools_layout.addWidget(
self._make_button("Show BEC position devices", self._daq.bec_list_all_devices, "Listing BEC devices."))
self._make_button(
"Show BEC position devices", self._daq.bec_list_all_devices, "Listing BEC devices."
)
)
tools_layout.addWidget(
self._make_button(
"Reinitialise BEC planner/devices",
@@ -31,15 +31,14 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
raster_alpha_changed = Signal(int)
def __init__(
self,
raster_mgr: RasterGridManager,
diffraction: DiffractionGeometry,
parent=None,
self, raster_mgr: RasterGridManager, diffraction: DiffractionGeometry, parent=None
):
super().__init__(diffraction=diffraction,
default_dtz=raster_mgr.active_grid.dtz,
default_transmission=raster_mgr.active_grid.transmission,
parent=parent)
super().__init__(
diffraction=diffraction,
default_dtz=raster_mgr.active_grid.dtz,
default_transmission=raster_mgr.active_grid.transmission,
parent=parent,
)
self._previous_sample_was_none_raster = True
+30 -11
View File
@@ -39,8 +39,13 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
super().__init__(
parent=parent,
diffraction=diffraction,
default_dtz=cfg_get("daq.data_collection_settings.default_rotation_settings.dtz", default_dtz),
default_transmission=cfg_get("daq.data_collection_settings.default_rotation_settings.transmission", default_transmission),
default_dtz=cfg_get(
"daq.data_collection_settings.default_rotation_settings.dtz", default_dtz
),
default_transmission=cfg_get(
"daq.data_collection_settings.default_rotation_settings.transmission",
default_transmission,
),
)
self.__beamline_state = None
@@ -71,8 +76,12 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
self._layout.addWidget(QLabel("°", parent=self), 5, 4)
self._layout.addWidget(QLabel("Image time", parent=self), 6, 0)
default_screening_exp_time = cfg_get("daq.data_collection_settings.default_screening_settings.exp_time_s", 0.1)
self.screening_image_time_enter = NumberLineEdit(0.0005, 10.0, default_screening_exp_time, decimals=4, parent=self)
default_screening_exp_time = cfg_get(
"daq.data_collection_settings.default_screening_settings.exp_time_s", 0.1
)
self.screening_image_time_enter = NumberLineEdit(
0.0005, 10.0, default_screening_exp_time, decimals=4, parent=self
)
self._layout.addWidget(self.screening_image_time_enter, 6, 1, 1, 3)
self._layout.addWidget(QLabel("s", parent=self), 6, 4)
@@ -96,22 +105,32 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
)
self._layout.addWidget(QLabel("Total angle", parent=self), 10, 0)
default_steps = cfg_get("daq.data_collection_settings.default_rotation_settings.steps", 1800)
default_increment_omega = cfg_get("daq.data_collection_settings.default_rotation_settings.increment_omega_deg", 0.2)
default_total_angle = default_steps*default_increment_omega
self.total_angle = DbOverrideLineEdit(0, 9999.0, default=default_total_angle, decimals=3, parent=self)
default_steps = cfg_get(
"daq.data_collection_settings.default_rotation_settings.steps", 1800
)
default_increment_omega = cfg_get(
"daq.data_collection_settings.default_rotation_settings.increment_omega_deg", 0.2
)
default_total_angle = default_steps * default_increment_omega
self.total_angle = DbOverrideLineEdit(
0, 9999.0, default=default_total_angle, decimals=3, parent=self
)
self._layout.addWidget(self.total_angle, 10, 1, 1, 3)
self._layout.addWidget(QLabel("°", parent=self), 10, 4)
self._register_override_field(self.total_angle)
self._layout.addWidget(QLabel("Image angle", parent=self), 11, 0)
self.image_angle = DbOverrideLineEdit(0, 10.0, default=default_increment_omega, decimals=3, parent=self)
self.image_angle = DbOverrideLineEdit(
0, 10.0, default=default_increment_omega, decimals=3, parent=self
)
self._layout.addWidget(self.image_angle, 11, 1, 1, 3)
self._layout.addWidget(QLabel("°", parent=self), 11, 4)
self._register_override_field(self.image_angle)
# TODO add protection on X10SA to prevent too short exposure time/ too high detector rep rate
self._layout.addWidget(QLabel("Image time", parent=self), 12, 0)
default_image_exp_time = cfg_get("daq.data_collection_settings.default_rotation_settings.exp_time_s", 0.01)
default_image_exp_time = cfg_get(
"daq.data_collection_settings.default_rotation_settings.exp_time_s", 0.01
)
self.image_time_enter = DbOverrideLineEdit(
0.0005, 10.0, default=default_image_exp_time, decimals=4, parent=self
)
@@ -145,7 +164,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
self.measurement_button.clicked.connect(self.run_measurement)
self._layout.addWidget(self.measurement_button, 16, 0, 1, 6)
self._reset_to_defaults()
@Slot()
def run_screening(self):
if self.__beamline_state != BeamlineStateEnum.SampleAlignment:
+1 -1
View File
@@ -196,7 +196,7 @@ class ScanSettingsPanel(QWidget):
else:
if not self._previous_sample_was_none:
logger.info("Sample cleared, resetting parameters to defaults")
#self._reset_to_defaults()
# self._reset_to_defaults()
self._previous_sample_was_none = True # Mark that sample is now None
self._sample = None
@@ -55,7 +55,7 @@ class InteractiveChartView(QChartView):
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton:
click_pos = event.position()
chart_pos = self.chart().mapToValue(click_pos.toPoint())
self.chart().mapToValue(click_pos.toPoint())
if self._panel._try_select_series_at_point(click_pos.toPoint()):
event.accept()
@@ -140,8 +140,12 @@ class RasterGridManager(QObject):
smargon_top_left=self.__geom.smargon,
grid_size_mm=Coordinate(x=0.8 * self.__beam_size_mm.x, y=0.8 * self.__beam_size_mm.y),
omega_deg=self.__geom.omega_deg,
exp_time_s=cfg_get("daq.data_collection_settings.default_raster_settings.exp_time_s", 0.01),
transmission=cfg_get("daq.data_collection_settings.default_raster_settings.transmission", 1.0),
exp_time_s=cfg_get(
"daq.data_collection_settings.default_raster_settings.exp_time_s", 0.01
),
transmission=cfg_get(
"daq.data_collection_settings.default_raster_settings.transmission", 1.0
),
dtz=cfg_get("daq.data_collection_settings.default_raster_settings.dtz", 200.0),
)
self.__completed_grids: List[CompletedRasterGridElem] = []
@@ -3,7 +3,6 @@ from aarecommon.models.rotation_scan import CompletedRotationScan
from PySide6.QtCore import QObject, Signal, Slot
class RotationScanManager(QObject):
file_ready = Signal(str, int)
+2 -2
View File
@@ -64,7 +64,7 @@ class JFJochDBusClient(QObject):
path = "/sls/mx/data/" + name + "_master.h5"
print(f"Load dataset: {path} image: {number}")
if self._ensure_interface():
reply = self.__interface.call("LoadFile", path, number, 1)
self.__interface.call("LoadFile", path, number, 1)
@Slot()
def load_online(self):
@@ -73,4 +73,4 @@ class JFJochDBusClient(QObject):
return
if self._ensure_interface():
reply = self.__interface.call("LoadFile", self.__detector_url, -1, 1)
self.__interface.call("LoadFile", self.__detector_url, -1, 1)
+2 -2
View File
@@ -554,13 +554,13 @@ class TutorialManager(QObject):
return None
def _run_setup_actions(self, step: TutorialStep) -> None:
def _run_setup_actions(self, step: TutorialStepDefinition) -> None:
if self.context is None:
return
for action in step.setup_actions:
self.action_executor.execute_action(action, self.context)
def _run_cleanup_actions(self, step: TutorialStep) -> None:
def _run_cleanup_actions(self, step: TutorialStepDefinition) -> None:
if self.context is None:
return
for action in step.cleanup_actions:
+6 -1
View File
@@ -14,6 +14,7 @@ from aare.gui.tutorials.tutorial_models import (
TutorialContext,
TutorialEvent,
TutorialScenario,
TutorialStepDefinition,
TutorialTarget,
TutorialTextRef,
)
@@ -188,7 +189,11 @@ class CompletionEvaluator:
self._event_bus = event_bus
def is_step_complete(
self, step: TutorialStep, context: TutorialContext, *, target_clicked: bool = False
self,
step: TutorialStepDefinition,
context: TutorialContext,
*,
target_clicked: bool = False,
) -> bool:
if step.completion is None:
return True
@@ -151,7 +151,8 @@ def test_grid_padding_fraction_scales(monkeypatch):
def test_crystal_union_extends_grid_only_when_enabled(monkeypatch):
# crystal extends well beyond the loop_face box on +x
mlbox = lambda: _fake_mlbox(loop_face=(150, 150, 300, 300), crystals=[(350, 150, 520, 300)])
def mlbox():
return _fake_mlbox(loop_face=(150, 150, 300, 300), crystals=[(350, 150, 520, 300)])
def cfg(enabled):
return lambda k, d=None: (
+1 -6
View File
@@ -23,12 +23,7 @@ with patch.dict("os.environ", {"JWT_AAREDAQ_KEY": "test_secret"}):
respond_to_baton_request,
)
from aarecommon.errors.exception_handler import AuthenticationException, UserRightsException
from aarecommon.models.auth import (
BatonHolderInfo,
BatonRequest,
BatonRequestStatus,
BatonStatus,
)
from aarecommon.models.auth import BatonHolderInfo, BatonRequest, BatonRequestStatus, BatonStatus
from aarecommon.models.models import SessionsStateEnum
+1 -1
View File
@@ -45,7 +45,7 @@ def test_beamcenter_fit_no_converge():
# beamcenter_fit might still find some contour if there's enough noise,
# but curve_fit might fail to converge
# If it doesn't converge, it returns None now.
result = beamcenter_fit(image)
beamcenter_fit(image)
# It might actually return a result if it finds a random blob,
# but we want to test the failure path.
# To truly force non-convergence we might need a more extreme case,
-1
View File
@@ -2,7 +2,6 @@ import os
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
os.environ["JWT_AAREDAQ_KEY"] = "test_key_for_unit_testing"
@@ -7,7 +7,7 @@ from aare.devices.experimental_hutch_shutter import ExperimentalHutchShutter
@patch("aare.devices.experimental_hutch_shutter.PV")
def test_shutter_init(mock_pv):
shutter = ExperimentalHutchShutter(MXBeamline.X06DA)
ExperimentalHutchShutter(MXBeamline.X06DA)
assert mock_pv.call_count == 3
# Check if PV names are correct
args = [call.args[0] for call in mock_pv.call_args_list]
@@ -8,7 +8,7 @@ from aare.devices.filter_transmission import FilterTransmission
@patch("aare.devices.filter_transmission.PV")
def test_filter_init(mock_pv):
filters = FilterTransmission(MXBeamline.X06DA)
FilterTransmission(MXBeamline.X06DA)
assert mock_pv.call_count == 3
+1
View File
@@ -31,6 +31,7 @@ def test_timer_box_auto_accept(qtbot):
assert box.result() == QMessageBox.StandardButton.Yes
@pytest.mark.timeout(10)
def test_ring_current_low_check_ok(qtbot):
# Should return True immediately if current is high enough