Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 129bfeb136 | |||
| 6ef7020150 | |||
| f59c77f142 | |||
| 4d2a4c5496 | |||
| ad6991208a | |||
| 8a023aff5a | |||
| f5a6b20eb8 | |||
| 624da08a27 | |||
| eb5a9c89ca | |||
| 4bb274d3c8 | |||
| 3e340334cf | |||
| abaf0867cd | |||
| 5ceebaa8f6 | |||
| fc4e33ac93 | |||
| e77f9af9ca | |||
| e38269b96f | |||
| 3940cb2da1 | |||
| c79b919ed6 | |||
| 43c84237a9 | |||
| 588316262c | |||
| 2cad486156 | |||
| 689089fbbb | |||
| 6b80009bec | |||
| 0bded12b4e | |||
| 6fedf7091f | |||
| 54fda2508b | |||
| 19b2299840 | |||
| d18c099058 | |||
| 5ca9972383 | |||
| 3b9a86c8c8 | |||
| e64b5b2c3d | |||
| 284914dc53 | |||
| d8a178ae13 | |||
| e79a3f785a | |||
| 963b775200 | |||
| 9ea249fff7 | |||
| 2930673bbc | |||
| 6a45a6a357 | |||
| a794e3f60d | |||
| 6b4a175f78 | |||
| c8a1add697 | |||
| a5642b5db2 | |||
| d1c2dbb46b | |||
| 93d79eccd4 | |||
| 1e81aa34b9 | |||
| c5b97bd592 | |||
| 42d518c2e4 | |||
| 9a40cbd8ae | |||
| 59bd4aeb9a | |||
| a455a490c6 | |||
| 22c46f8f8e | |||
| 4b76d1b191 | |||
| 4fc31e5f5d | |||
| 286c7a4bff | |||
| 7ab682817f | |||
| ac3d82f7ef | |||
| 023e0aab2e | |||
| 21bd57393f | |||
| 82d51649ee | |||
| 015bf2ee3b | |||
| 24302c244d | |||
| a8990f8de2 | |||
| 8da2ed4102 | |||
| 03a5850bbf | |||
| d3d016108e | |||
| 0b99a82ae9 | |||
| add46d8b0d | |||
| 78c75b1769 | |||
| 434ddad89a | |||
| b0703552f2 | |||
| 14ca9bd74a | |||
| 2563471ac8 | |||
| 23aadabfd1 | |||
| 3bf21ff647 | |||
| 602317faa8 | |||
| 13c6d7b8fb | |||
| e32642526f | |||
| e7fd8e453d | |||
| 4a6e4092ca | |||
| f22487a45b | |||
| ace0303301 | |||
| 8c4ade4034 | |||
| a799321f98 | |||
| 989dc4be36 | |||
| 65800812a5 | |||
| 12803b4b6f | |||
| 2ee2b25c21 | |||
| 1834e6f55d | |||
| 1a204693dc | |||
| 20dff942c1 | |||
| eec897f713 | |||
| 5c4a0f92bc | |||
| b9f0574876 | |||
| f0da85e930 | |||
| 8bb7d2332c | |||
| 6b32ee6ee9 | |||
| 84eb0c74c6 | |||
| 824706d1d8 | |||
| 7cd2a4d44c | |||
| 2a1180cc90 | |||
| a32d0821ed | |||
| a6f8d6936e | |||
| fa52c5eee1 | |||
| 1448c6b82a | |||
| f768808776 | |||
| 265c09d746 | |||
| cae8614cb5 | |||
| be5c35a938 | |||
| 2d18332e23 | |||
| b9d471361a | |||
| ce4d57c597 | |||
| e81c8b1484 | |||
| 1a84335bbb | |||
| e87598bc7e | |||
| 106f3bed6e | |||
| 1de99e47d6 | |||
| 0cdb8e3ca6 | |||
| 3887cdb835 | |||
| d89ba54768 | |||
| 04b08b9d21 |
@@ -0,0 +1,9 @@
|
||||
# Do not edit this file!
|
||||
# It is needed to track the repo template version, and editing may break things.
|
||||
# This file will be overwritten by copier on template updates.
|
||||
|
||||
_commit: v1.4.0
|
||||
_src_path: https://github.com/bec-project/plugin_copier_template.git
|
||||
make_commit: false
|
||||
project_name: pxiii_bec
|
||||
widget_plugins_input: null
|
||||
@@ -0,0 +1,3 @@
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
semantic-release changelog -D version_variable=$SCRIPT_DIR/../../semantic_release/__init__.py:__version__
|
||||
semantic-release version -D version_variable=$SCRIPT_DIR/../../semantic_release/__init__.py:__version__
|
||||
@@ -0,0 +1,3 @@
|
||||
black --line-length=100 $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
isort --line-length=100 --profile=black --multi-line=3 --trailing-comma $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
git add $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
@@ -0,0 +1,102 @@
|
||||
name: CI for pxiii_bec
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: "Branch of BEC Widgets to install"
|
||||
required: false
|
||||
type: string
|
||||
default: "main"
|
||||
BEC_CORE_BRANCH:
|
||||
description: "Branch of BEC Core to install"
|
||||
required: false
|
||||
type: string
|
||||
default: "main"
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: "Branch of Ophyd Devices to install"
|
||||
required: false
|
||||
type: string
|
||||
default: "main"
|
||||
BEC_PLUGIN_REPO_BRANCH:
|
||||
description: "Branch of the BEC Plugin Repository to install"
|
||||
required: false
|
||||
type: string
|
||||
default: "main"
|
||||
PYTHON_VERSION:
|
||||
description: "Python version to use"
|
||||
required: false
|
||||
type: string
|
||||
default: "3.12"
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "${{ inputs.PYTHON_VERSION || '3.12' }}"
|
||||
|
||||
- name: Checkout BEC Plugin Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec/pxiii_bec
|
||||
ref: "${{ inputs.BEC_PLUGIN_REPO_BRANCH || github.head_ref || github.sha }}"
|
||||
path: ./pxiii_bec
|
||||
|
||||
- name: Lint for merge conflicts from template updates
|
||||
shell: bash
|
||||
# Find all Copier conflicts except this line
|
||||
run: '! grep -r "<<<<<<< before updating" | grep -v "grep -r \"<<<<<<< before updating"'
|
||||
|
||||
- name: Checkout BEC Core
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec/bec
|
||||
ref: "${{ inputs.BEC_CORE_BRANCH || 'main' }}"
|
||||
path: ./bec
|
||||
|
||||
- name: Checkout Ophyd Devices
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec/ophyd_devices
|
||||
ref: "${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}"
|
||||
path: ./ophyd_devices
|
||||
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec/bec_widgets
|
||||
ref: "${{ inputs.BEC_WIDGETS_BRANCH || 'main' }}"
|
||||
path: ./bec_widgets
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
|
||||
- name: Install Python dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip install --system -e ./ophyd_devices
|
||||
uv pip install --system -e ./bec/bec_lib[dev]
|
||||
uv pip install --system -e ./bec/bec_ipython_client
|
||||
uv pip install --system -e ./bec/bec_server[dev]
|
||||
uv pip install --system -e ./bec_widgets[dev,pyside6]
|
||||
uv pip install --system -e ./pxiii_bec
|
||||
|
||||
- name: Run Pytest with Coverage
|
||||
id: coverage
|
||||
run: pytest --random-order --cov=./pxiii_bec --cov-config=./pxiii_bec/pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail ./pxiii_bec/tests/ || test $? -eq 5
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Create template upgrade PR for pxiii_bec
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
create_update_branch_and_pr:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create virtualenv
|
||||
run: |
|
||||
python -m virtualenv .venv
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pip install copier PySide6 bec_lib
|
||||
|
||||
- name: Perform update
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
git config --global user.email "bec_ci_staging@psi.ch"
|
||||
git config --global user.name "BEC automated CI"
|
||||
|
||||
branch="chore/update-template-$(python -m uuid)"
|
||||
echo "switching to branch $branch"
|
||||
git checkout -b $branch
|
||||
|
||||
echo "Running copier update..."
|
||||
copier update --trust --defaults --conflict inline 2>&1 | tee copier.log
|
||||
status=${PIPESTATUS[0]}
|
||||
output="$(cat copier.log)"
|
||||
echo $output
|
||||
msg="$(printf '%s\n' "$output" | head -n 1)"
|
||||
|
||||
if ! grep -q "make_commit: true" .copier-answers.yml ; then
|
||||
echo "Autocommit not made, committing..."
|
||||
git add -A
|
||||
git commit -a -m "$msg"
|
||||
fi
|
||||
|
||||
if diff-index --quiet HEAD ; then
|
||||
echo "No changes detected"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git push -u origin $branch
|
||||
curl -X POST "https://gitea.psi.ch/api/v1/repos/${{ gitea.repository }}/pulls" \
|
||||
-H "Authorization: token ${{ secrets.CI_REPO_WRITE }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"Template: $(echo $msg)\",
|
||||
\"body\": \"This PR was created by Gitea Actions\",
|
||||
\"head\": \"$(echo $branch)\",
|
||||
\"base\": \"main\"
|
||||
}"
|
||||
@@ -8,6 +8,9 @@
|
||||
**/.pytest_cache
|
||||
**/*.egg*
|
||||
|
||||
# recovery_config files
|
||||
recovery_config_*
|
||||
|
||||
# file writer data
|
||||
**.h5
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2025, Paul Scherrer Institute
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -1 +0,0 @@
|
||||
from .bec_client import *
|
||||
@@ -1 +0,0 @@
|
||||
from .plugins import *
|
||||
@@ -1,245 +0,0 @@
|
||||
from bec_client.scan_manager import ScanReport
|
||||
from bec_utils.devicemanager import Device
|
||||
|
||||
# pylint:disable=undefined-variable
|
||||
# pylint: disable=too-many-arguments
|
||||
|
||||
|
||||
def dscan(
|
||||
motor1: Device, m1_from: float, m1_to: float, steps: int, exp_time: float, **kwargs
|
||||
) -> ScanReport:
|
||||
"""Relative line scan with one device.
|
||||
|
||||
Args:
|
||||
motor1 (Device): Device that should be scanned.
|
||||
m1_from (float): Start position relative to the current position.
|
||||
m1_to (float): End position relative to the current position.
|
||||
steps (int): Number of steps.
|
||||
exp_time (float): Exposure time.
|
||||
|
||||
Returns:
|
||||
ScanReport: Status object.
|
||||
|
||||
Examples:
|
||||
>>> dscan(dev.motor1, -5, 5, 10, 0.1)
|
||||
"""
|
||||
return scans.line_scan(
|
||||
motor1, m1_from, m1_to, steps=steps, exp_time=exp_time, relative=True, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def d2scan(
|
||||
motor1: Device,
|
||||
m1_from: float,
|
||||
m1_to: float,
|
||||
motor2: Device,
|
||||
m2_from: float,
|
||||
m2_to: float,
|
||||
steps: int,
|
||||
exp_time: float,
|
||||
**kwargs
|
||||
) -> ScanReport:
|
||||
"""Relative line scan with two devices.
|
||||
|
||||
Args:
|
||||
motor1 (Device): First device that should be scanned.
|
||||
m1_from (float): Start position of the first device relative to its current position.
|
||||
m1_to (float): End position of the first device relative to its current position.
|
||||
motor2 (Device): Second device that should be scanned.
|
||||
m2_from (float): Start position of the second device relative to its current position.
|
||||
m2_to (float): End position of the second device relative to its current position.
|
||||
steps (int): Number of steps.
|
||||
exp_time (float): Exposure time
|
||||
|
||||
Returns:
|
||||
ScanReport: Status object.
|
||||
|
||||
Examples:
|
||||
>>> d2scan(dev.motor1, -5, 5, dev.motor2, -8, 8, 10, 0.1)
|
||||
"""
|
||||
return scans.line_scan(
|
||||
motor1,
|
||||
m1_from,
|
||||
m1_to,
|
||||
motor2,
|
||||
m2_from,
|
||||
m2_to,
|
||||
steps=steps,
|
||||
exp_time=exp_time,
|
||||
relative=True,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def ascan(motor1, m1_from, m1_to, steps, exp_time, **kwargs):
|
||||
"""Absolute line scan with one device.
|
||||
|
||||
Args:
|
||||
motor1 (Device): Device that should be scanned.
|
||||
m1_from (float): Start position.
|
||||
m1_to (float): End position.
|
||||
steps (int): Number of steps.
|
||||
exp_time (float): Exposure time.
|
||||
|
||||
Returns:
|
||||
ScanReport: Status object.
|
||||
|
||||
Examples:
|
||||
>>> ascan(dev.motor1, -5, 5, 10, 0.1)
|
||||
"""
|
||||
return scans.line_scan(
|
||||
motor1, m1_from, m1_to, steps=steps, exp_time=exp_time, relative=False, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def a2scan(motor1, m1_from, m1_to, motor2, m2_from, m2_to, steps, exp_time, **kwargs):
|
||||
"""Absolute line scan with two devices.
|
||||
|
||||
Args:
|
||||
motor1 (Device): First device that should be scanned.
|
||||
m1_from (float): Start position of the first device.
|
||||
m1_to (float): End position of the first device.
|
||||
motor2 (Device): Second device that should be scanned.
|
||||
m2_from (float): Start position of the second device.
|
||||
m2_to (float): End position of the second device.
|
||||
steps (int): Number of steps.
|
||||
exp_time (float): Exposure time
|
||||
|
||||
Returns:
|
||||
ScanReport: Status object.
|
||||
|
||||
Examples:
|
||||
>>> a2scan(dev.motor1, -5, 5, dev.motor2, -8, 8, 10, 0.1)
|
||||
"""
|
||||
return scans.line_scan(
|
||||
motor1,
|
||||
m1_from,
|
||||
m1_to,
|
||||
motor2,
|
||||
m2_from,
|
||||
m2_to,
|
||||
steps=steps,
|
||||
exp_time=exp_time,
|
||||
relative=False,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def dmesh(motor1, m1_from, m1_to, m1_steps, motor2, m2_from, m2_to, m2_steps, exp_time, **kwargs):
|
||||
"""Relative mesh scan (grid scan) with two devices.
|
||||
|
||||
Args:
|
||||
motor1 (Device): First device that should be scanned.
|
||||
m1_from (float): Start position of the first device relative to its current position.
|
||||
m1_to (float): End position of the first device relative to its current position.
|
||||
m1_steps (int): Number of steps for motor1.
|
||||
motor2 (Device): Second device that should be scanned.
|
||||
m2_from (float): Start position of the second device relative to its current position.
|
||||
m2_to (float): End position of the second device relative to its current position.
|
||||
m2_steps (int): Number of steps for motor2.
|
||||
exp_time (float): Exposure time
|
||||
|
||||
Returns:
|
||||
ScanReport: Status object.
|
||||
|
||||
Examples:
|
||||
>>> dmesh(dev.motor1, -5, 5, 10, dev.motor2, -8, 8, 10, 0.1)
|
||||
"""
|
||||
return scans.grid_scan(
|
||||
motor1,
|
||||
m1_from,
|
||||
m1_to,
|
||||
m1_steps,
|
||||
motor2,
|
||||
m2_from,
|
||||
m2_to,
|
||||
m2_steps,
|
||||
exp_time=exp_time,
|
||||
relative=True,
|
||||
)
|
||||
|
||||
|
||||
def amesh(motor1, m1_from, m1_to, m1_steps, motor2, m2_from, m2_to, m2_steps, exp_time, **kwargs):
|
||||
"""Absolute mesh scan (grid scan) with two devices.
|
||||
|
||||
Args:
|
||||
motor1 (Device): First device that should be scanned.
|
||||
m1_from (float): Start position of the first device.
|
||||
m1_to (float): End position of the first device.
|
||||
m1_steps (int): Number of steps for motor1.
|
||||
motor2 (Device): Second device that should be scanned.
|
||||
m2_from (float): Start position of the second device.
|
||||
m2_to (float): End position of the second device.
|
||||
m2_steps (int): Number of steps for motor2.
|
||||
exp_time (float): Exposure time
|
||||
|
||||
Returns:
|
||||
ScanReport: Status object.
|
||||
|
||||
Examples:
|
||||
>>> amesh(dev.motor1, -5, 5, 10, dev.motor2, -8, 8, 10, 0.1)
|
||||
"""
|
||||
return scans.grid_scan(
|
||||
motor1,
|
||||
m1_from,
|
||||
m1_to,
|
||||
m1_steps,
|
||||
motor2,
|
||||
m2_from,
|
||||
m2_to,
|
||||
m2_steps,
|
||||
exp_time=exp_time,
|
||||
relative=False,
|
||||
)
|
||||
|
||||
|
||||
def umv(*args) -> ScanReport:
|
||||
"""Updated absolute move (i.e. blocking) for one or more devices.
|
||||
|
||||
Returns:
|
||||
ScanReport: Status object.
|
||||
|
||||
Examples:
|
||||
>>> umv(dev.samx, 1)
|
||||
>>> umv(dev.samx, 1, dev.samy, 2)
|
||||
"""
|
||||
return scans.umv(*args, relative=False)
|
||||
|
||||
|
||||
def umvr(*args) -> ScanReport:
|
||||
"""Updated relative move (i.e. blocking) for one or more devices.
|
||||
|
||||
Returns:
|
||||
ScanReport: Status object.
|
||||
|
||||
Examples:
|
||||
>>> umvr(dev.samx, 1)
|
||||
>>> umvr(dev.samx, 1, dev.samy, 2)
|
||||
"""
|
||||
return scans.umv(*args, relative=True)
|
||||
|
||||
|
||||
def mv(*args) -> ScanReport:
|
||||
"""Absolute move for one or more devices.
|
||||
|
||||
Returns:
|
||||
ScanReport: Status object.
|
||||
|
||||
Examples:
|
||||
>>> mv(dev.samx, 1)
|
||||
>>> mv(dev.samx, 1, dev.samy, 2)
|
||||
"""
|
||||
return scans.mv(*args, relative=False)
|
||||
|
||||
|
||||
def mvr(*args) -> ScanReport:
|
||||
"""Relative move for one or more devices.
|
||||
|
||||
Returns:
|
||||
ScanReport: Status object.
|
||||
|
||||
Examples:
|
||||
>>> mvr(dev.samx, 1)
|
||||
>>> mvr(dev.samx, 1, dev.samy, 2)
|
||||
"""
|
||||
return scans.mv(*args, relative=True)
|
||||
@@ -1,25 +0,0 @@
|
||||
"""
|
||||
Pre-startup script for BEC client. This script is executed before the BEC client
|
||||
is started. It can be used to set up the BEC client configuration. The script is
|
||||
executed in the global namespace of the BEC client. This means that all
|
||||
variables defined here are available in the BEC client.
|
||||
|
||||
To set up the BEC client configuration, use the ServiceConfig class. For example,
|
||||
to set the configuration file path, add the following lines to the script:
|
||||
|
||||
import pathlib
|
||||
from bec_lib.core import ServiceConfig
|
||||
|
||||
current_path = pathlib.Path(__file__).parent.resolve()
|
||||
CONFIG_PATH = f"{current_path}/<path_to_my_config_file.yaml>"
|
||||
|
||||
config = ServiceConfig(CONFIG_PATH)
|
||||
|
||||
If this startup script defined a ServiceConfig object, the BEC client will use
|
||||
it to configure itself. Otherwise, the BEC client will use the default config.
|
||||
"""
|
||||
|
||||
# example:
|
||||
# current_path = pathlib.Path(__file__).parent.resolve()
|
||||
# CONFIG_PATH = f"{current_path}/../../../bec_config.yaml"
|
||||
# config = ServiceConfig(CONFIG_PATH)
|
||||
@@ -1 +0,0 @@
|
||||
from .saxs_imaging_processor import SaxsImagingProcessor
|
||||
@@ -1,430 +0,0 @@
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
import time
|
||||
from queue import Queue
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from data_processing.stream_processor import StreamProcessor
|
||||
from bec_lib.core import BECMessage
|
||||
from bec_lib.core.redis_connector import MessageObject, RedisConnector
|
||||
|
||||
|
||||
class SaxsImagingProcessor(StreamProcessor):
|
||||
def __init__(self, connector: RedisConnector, config: dict) -> None:
|
||||
""""""
|
||||
super().__init__(connector, config)
|
||||
self.metadata_consumer = None
|
||||
self.parameter_consumer = None
|
||||
self.metadata = {}
|
||||
self.num_received_msgs = 0
|
||||
self.queue = Queue()
|
||||
self._init_parameter(endpoint="px_stream/gui_event")
|
||||
self.start_parameter_consumer(endpoint="px_stream/gui_event")
|
||||
self._init_metadata_and_proj_nr(endpoint="px_stream/proj_nr")
|
||||
self.start_metadata_consumer(endpoint="px_stream/projection_*/metadata")
|
||||
|
||||
def _init_parameter(self, endpoint: str) -> None:
|
||||
"""Initialize the parameters azi_angle, contrast and horiz_roi.
|
||||
|
||||
Args:
|
||||
endpoint (str): Endpoint for redis topic.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
self.azi_angle = None
|
||||
self.horiz_roi = [20, 50]
|
||||
self.contrast = 0
|
||||
msg = self.producer.get(topic=endpoint)
|
||||
if msg is None:
|
||||
return None
|
||||
msg_raw = BECMessage.DeviceMessage.loads(msg)
|
||||
self._parameter_msg_handler(msg_raw)
|
||||
|
||||
def start_parameter_consumer(self, endpoint: str) -> None:
|
||||
"""Initialize the consumers for gui_event parameters.
|
||||
Consumer is started with a callback function that updates
|
||||
the parameters: azi_angle, contrast and horiz_roi.
|
||||
|
||||
Args:
|
||||
endpoint (str): Endpoint for redis topic.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
if self.parameter_consumer and self.parameter_consumer.is_alive():
|
||||
self.parameter_consumer.shutdown()
|
||||
self.parameter_consumer = self._connector.consumer(
|
||||
pattern=endpoint, cb=self._update_parameter_cb, parent=self
|
||||
)
|
||||
self.parameter_consumer.start()
|
||||
|
||||
@staticmethod
|
||||
def _update_parameter_cb(msg: MessageObject, parent: SaxsImagingProcessor) -> None:
|
||||
"""Callback function for the parameter consumer.
|
||||
|
||||
Args:
|
||||
msg (MessageObject): Message object.
|
||||
parent (SaxsImagingProcessor): Parent class.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
msg_raw = BECMessage.DeviceMessage.loads(msg.value)
|
||||
parent._parameter_msg_handler(msg_raw)
|
||||
|
||||
def _parameter_msg_handler(self, msg: BECMessage) -> None:
|
||||
"""Handle the parameter message.
|
||||
There can be updates on three different parameters:
|
||||
azi_angle, contrast and horiz_roi.
|
||||
|
||||
Args:
|
||||
msg (BECMessage): Message object.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
if msg.content["signals"].get("horiz_roi") is not None:
|
||||
self.horiz_roi = msg.content["signals"]["horiz_roi"]
|
||||
if msg.content["signals"].get("azi_angles") is not None:
|
||||
self.azi_angle = msg.content["signals"]["azi_angle"]
|
||||
if msg.content["signals"].get("contrast") is not None:
|
||||
self.contrast = msg.content["signals"]["contrast"]
|
||||
# self._init_parameter_updated = True
|
||||
# if len(self.metadata) > 0:
|
||||
# self._update_queue(self.metadata[self.proj_nr], self.proj_nr)
|
||||
|
||||
def _init_metadata_and_proj_nr(self, endpoint: str) -> None:
|
||||
"""Initialize the metadata and proj_nr.
|
||||
|
||||
Args:
|
||||
endpoint (str): Endpoint for redis topic.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
msg = self.producer.get(topic=endpoint)
|
||||
if msg is None:
|
||||
self.proj_nr = None
|
||||
return None
|
||||
msg_raw = BECMessage.DeviceMessage.loads(msg)
|
||||
self.proj_nr = msg_raw.content["signals"]["proj_nr"]
|
||||
# TODO hardcoded endpoint, possibe to use more general solution?
|
||||
msg = self.producer.get(topic=f"px_stream/projection_{self.proj_nr}/metadata")
|
||||
msg_raw = BECMessage.DeviceMessage.loads(msg)
|
||||
self._update_queue(msg_raw.content["signals"], self.proj_nr)
|
||||
|
||||
def _update_queue(self, metadata: dict, proj_nr: int) -> None:
|
||||
"""Update the process queue.
|
||||
|
||||
Args:
|
||||
metadata (dict): Metadata for the projection.
|
||||
proj_nr (int): Projection number.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
self.metadata.update({proj_nr: metadata})
|
||||
self.queue.put((proj_nr, metadata))
|
||||
|
||||
def start_metadata_consumer(self, endpoint: str) -> None:
|
||||
"""Start the metadata consumer.
|
||||
Consumer is started with a callback function that updates the metadata.
|
||||
|
||||
Args:
|
||||
endpoint (str): Endpoint for redis topic.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
if self.metadata_consumer and self.metadata_consumer.is_alive():
|
||||
self.metadata_consumer.shutdown()
|
||||
self.metadata_consumer = self._connector.consumer(
|
||||
pattern=endpoint, cb=self._update_metadata_cb, parent=self
|
||||
)
|
||||
self.metadata_consumer.start()
|
||||
|
||||
@staticmethod
|
||||
def _update_metadata_cb(msg: MessageObject, parent: SaxsImagingProcessor) -> None:
|
||||
"""Callback function for the metadata consumer.
|
||||
|
||||
Args:
|
||||
msg (MessageObject): Message object.
|
||||
parent (SaxsImagingProcessor): Parent class.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
msg_raw = BECMessage.DeviceMessage.loads(msg.value)
|
||||
parent._metadata_msg_handler(msg_raw, msg.topic.decode())
|
||||
|
||||
def _metadata_msg_handler(self, msg: BECMessage, topic) -> None:
|
||||
"""Handle the metadata message.
|
||||
If self.metadata is larger than 10, the oldest entry is removed.
|
||||
|
||||
Args:
|
||||
msg (BECMessage): Message object.
|
||||
topic (str): Topic for the message.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
if len(self.metadata) > 10:
|
||||
first_key = next(iter(self.metadata))
|
||||
self.metadata.pop(first_key)
|
||||
self.proj_nr = int(topic.split("px_stream/projection_")[1].split("/")[0])
|
||||
self._update_queue(msg.content["signals"], self.proj_nr)
|
||||
|
||||
def start_data_consumer(self) -> None:
|
||||
"""function from the parent class that we don't want to use here"""
|
||||
pass
|
||||
|
||||
def _run_forever(self) -> None:
|
||||
"""Loop that runs forever when the processor is started.
|
||||
Upon update of the queue, the data is loaded and processed.
|
||||
This processing continues as long as the queue is empty,
|
||||
and proceeds to the next projection when the queue is updated.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
proj_nr, metadata = self.queue.get()
|
||||
self.num_received_msgs = 0
|
||||
self.data = None
|
||||
while self.queue.empty():
|
||||
start = time.time()
|
||||
self._get_data(proj_nr, metadata)
|
||||
start = time.time()
|
||||
result = self.process(self.data, metadata)
|
||||
print(f"Processing took {time.time() - start}")
|
||||
if result is None:
|
||||
continue
|
||||
print(f"Length of data is {result[0][0]['z'].shape}")
|
||||
msg = BECMessage.ProcessedDataMessage(data=result[0][0], metadata=result[1]).dumps()
|
||||
print("Publishing result")
|
||||
self._publish_result(msg)
|
||||
|
||||
def _get_data(self, proj_nr: int, metadata: dict) -> None:
|
||||
"""Get data for given proj_nr from redis.
|
||||
|
||||
Args:
|
||||
proj_nr (int): Projection number.
|
||||
|
||||
Returns:
|
||||
list: List of azimuthal integrated data.
|
||||
|
||||
"""
|
||||
start = time.time()
|
||||
msgs = self.producer.lrange(
|
||||
f"px_stream/projection_{proj_nr}/data", self.num_received_msgs, -1
|
||||
)
|
||||
print(f"Loading of {len(msgs)} took {time.time() - start}")
|
||||
if not msgs:
|
||||
return None
|
||||
|
||||
frame_shape = BECMessage.DeviceMessage.loads(msgs[0]).content["signals"]["data"].shape[-2:]
|
||||
|
||||
if self.data is None:
|
||||
start = time.time()
|
||||
self.data = np.empty(
|
||||
(
|
||||
metadata["metadata"]["number_of_rows"],
|
||||
metadata["metadata"]["number_of_columns"],
|
||||
*frame_shape,
|
||||
)
|
||||
)
|
||||
print(f"Init output took {time.time() - start}")
|
||||
start = time.time()
|
||||
for msg in msgs:
|
||||
self.data[
|
||||
self.num_received_msgs : self.num_received_msgs + 1, ...
|
||||
] = BECMessage.DeviceMessage.loads(msg).content["signals"]["data"]
|
||||
self.num_received_msgs += 1
|
||||
print(f"Casting data to array took {time.time() - start}")
|
||||
|
||||
def process(self, data: np.ndarray, metadata: dict) -> Optional[Tuple[dict, dict]]:
|
||||
"""Process the scanning SAXS data
|
||||
|
||||
Args:
|
||||
data (list): List of azimuthal integrated data.
|
||||
metadata (dict): Metadata for the projection.
|
||||
|
||||
Returns:
|
||||
Optional[Tuple[dict, dict]]: Processed data and metadata.
|
||||
|
||||
"""
|
||||
|
||||
if data is None:
|
||||
return None
|
||||
# TODO np.asarray is repsonsible for 95% of the processing time for function.
|
||||
azint_data = data[0 : self.num_received_msgs, ...]
|
||||
norm_sum = metadata["norm_sum"]
|
||||
q = metadata["q"]
|
||||
out = []
|
||||
|
||||
contrast = self.contrast
|
||||
horiz_roi = self.horiz_roi
|
||||
azi_angle = self.azi_angle
|
||||
if azi_angle is None:
|
||||
azi_angle = 0
|
||||
|
||||
f1amp, f2amp, f2phase = self._colorfulplot(
|
||||
horiz_roi=horiz_roi,
|
||||
q=q,
|
||||
norm_sum=norm_sum,
|
||||
data=azint_data,
|
||||
azi_angle=azi_angle,
|
||||
)
|
||||
if contrast == 0:
|
||||
out = f1amp
|
||||
elif contrast == 1:
|
||||
out = f2amp
|
||||
elif contrast == 2:
|
||||
out = f2phase
|
||||
|
||||
stream_output = {
|
||||
# 0: {"x": np.asarray(x), "y": np.asarray(y), "z": np.asarray(out)},
|
||||
0: {"z": np.asarray(out)},
|
||||
# "input": self.config["input_xy"],
|
||||
}
|
||||
metadata["grid_scan"] = out.shape
|
||||
|
||||
return (stream_output, metadata)
|
||||
|
||||
def _colorfulplot(
|
||||
self,
|
||||
horiz_roi: list,
|
||||
q: np.ndarray,
|
||||
norm_sum: np.ndarray,
|
||||
data: np.ndarray,
|
||||
azi_angle: float,
|
||||
percentile_value: int = 96,
|
||||
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Compute data for sSAXS colorful 2D plot.
|
||||
Pending: hsv_to_rgb conversion for colorful output
|
||||
|
||||
Args:
|
||||
horiz_roi (list): List with q edges for binning.
|
||||
q (np.ndarray): q values.
|
||||
norm_sum (np.ndarray): Normalization sum.
|
||||
data (np.ndarray): Data to be binned.
|
||||
azi_angle (float, optional): Azimuthal angle for first segment, shifts f2phase. Defaults to 0.
|
||||
percentile_value (int, optional): Percentile value for removing outliers above threshold. Defaults to 96, range 0...100.
|
||||
|
||||
Returns:
|
||||
Tuple[np.ndarray, np.ndarray, np.ndarray]: f1amp, f2amp, f2phase
|
||||
|
||||
"""
|
||||
|
||||
output, output_norm = self._bin_qrange(
|
||||
horiz_roi=horiz_roi, q=q, norm_sum=norm_sum, data=data
|
||||
)
|
||||
output_sym = self._sym_data(data=output, norm_sum=output_norm)
|
||||
output_sym = output_sym
|
||||
shape = output_sym.shape[0:2]
|
||||
|
||||
fft_data = np.fft.rfft(output_sym.reshape((-1, output_sym.shape[-2])), axis=1)
|
||||
|
||||
f1amp = np.abs(fft_data[:, 0]) / output_sym.shape[2]
|
||||
f2amp = 2 * np.abs(fft_data[:, 1]) / output_sym.shape[2]
|
||||
f2angle = np.angle(fft_data[:, 1]) + np.deg2rad(azi_angle)
|
||||
|
||||
f2phase = (f2angle + np.pi) / (2 * np.pi)
|
||||
f2phase[f2phase > 1] = f2phase[f2phase > 1] - 1
|
||||
|
||||
f1amp = f1amp.reshape(shape)
|
||||
f2amp = f2amp.reshape(shape)
|
||||
f2angle = f2angle.reshape(shape)
|
||||
f2phase = f2phase.reshape(shape)
|
||||
|
||||
h = f2phase
|
||||
|
||||
max_scale = np.percentile(f2amp, percentile_value)
|
||||
s = f2amp / max_scale
|
||||
s[s > 1] = 1
|
||||
|
||||
max_scale = np.percentile(f1amp, percentile_value)
|
||||
v = f1amp
|
||||
v = v / max_scale
|
||||
v[v > 1] = 1
|
||||
|
||||
# hsv = np.stack((h, s, v), axis=2)
|
||||
# comb_all = colors.hsv_to_rgb(hsv)
|
||||
|
||||
return f1amp, f2amp, f2phase # , comb_all
|
||||
|
||||
def _bin_qrange(self, horiz_roi, q, norm_sum, data) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""Reintegrate data for given q range.
|
||||
Weighted sum for data using norm_sum as weights
|
||||
|
||||
Args:
|
||||
horiz_roi (list): List with q edges for binning.
|
||||
q (np.ndarray): q values.
|
||||
norm_sum (np.ndarray): Normalization sum.
|
||||
data (np.ndarray): Data to be binned.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Binned data.
|
||||
np.ndarray: Binned normalization sum.
|
||||
"""
|
||||
|
||||
output = np.zeros((*data.shape[:-1], len(horiz_roi) - 1))
|
||||
output_norm = np.zeros((data.shape[-2], len(horiz_roi) - 1))
|
||||
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
q_mask = np.logical_and(q >= q[horiz_roi[0]], q <= q[horiz_roi[1]])
|
||||
output_norm[..., 0] = np.nansum(norm_sum[..., q_mask], axis=-1)
|
||||
output[..., 0] = np.nansum(data[..., q_mask] * norm_sum[..., q_mask], axis=-1)
|
||||
output[..., 0] = np.divide(
|
||||
output[..., 0], output_norm[..., 0], out=np.zeros_like(output[..., 0])
|
||||
)
|
||||
|
||||
return output, output_norm
|
||||
|
||||
def _sym_data(self, data, norm_sum) -> np.ndarray:
|
||||
"""Symmetrize data by averaging over the two opposing directions.
|
||||
Helpful to remove detector gaps for x-ray detectors
|
||||
|
||||
Args:
|
||||
data (np.ndarray): Data to be symmetrized.
|
||||
norm_sum (np.ndarray): Normalization sum.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Symmetrized data.
|
||||
|
||||
"""
|
||||
|
||||
n_directions = norm_sum.shape[0] // 2
|
||||
output = np.divide(
|
||||
data[..., :n_directions, :] * norm_sum[:n_directions, :]
|
||||
+ data[..., n_directions:, :] * norm_sum[n_directions:, :],
|
||||
norm_sum[:n_directions, :] + norm_sum[n_directions:, :],
|
||||
out=np.zeros_like(data[..., :n_directions, :]),
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
config = {
|
||||
"output": "px_dap_worker",
|
||||
}
|
||||
dap_process = SaxsImagingProcessor.run(config=config, connector_host=["localhost:6379"])
|
||||
@@ -1,155 +0,0 @@
|
||||
import os
|
||||
import h5py
|
||||
import numpy as np
|
||||
import time
|
||||
import json
|
||||
import argparse
|
||||
|
||||
from bec_lib.core import RedisConnector, BECMessage
|
||||
|
||||
|
||||
def load_data(datadir: str, metadata_path: str) -> tuple:
|
||||
"""Load data from disk
|
||||
|
||||
Args:
|
||||
datapath (str): Path to the data directory with data for projection (h5 files)
|
||||
metadata_path (str): Path to the metadata file
|
||||
|
||||
Returns:
|
||||
tuple: data, q, norm_sum, metadata
|
||||
|
||||
"""
|
||||
|
||||
with open(metadata_path) as file:
|
||||
metadata = json.load(file)
|
||||
|
||||
filenames = [fname for fname in os.listdir(datadir) if fname.endswith(".h5")]
|
||||
filenames.sort()
|
||||
|
||||
for ii, fname in enumerate(filenames):
|
||||
with h5py.File(os.path.join(datadir, fname), "r") as h5file:
|
||||
if ii == 0:
|
||||
q = h5file["q"][...].T.squeeze()
|
||||
norm_sum = h5file["norm_sum"][...]
|
||||
data = np.zeros((len(filenames), *h5file["I_all"][...].shape))
|
||||
data[ii, ...] = h5file["I_all"][...]
|
||||
|
||||
return data, q, norm_sum, metadata
|
||||
|
||||
|
||||
def _get_projection_keys(producer) -> list:
|
||||
"""Get all keys for projections with endpoint px_stream/projection_* in redis
|
||||
|
||||
Args:
|
||||
producer (RedisProducer): Redis producer
|
||||
|
||||
Returns:
|
||||
list: List of keys or [] if no keys are found"""
|
||||
keys = producer.keys("px_stream/projection_*")
|
||||
if not keys:
|
||||
return []
|
||||
return keys
|
||||
|
||||
|
||||
def send_data(
|
||||
data: np.ndarray,
|
||||
q: np.ndarray,
|
||||
norm_sum: np.ndarray,
|
||||
bec_producer: RedisConnector.producer,
|
||||
metadata: dict,
|
||||
proj_nr: int,
|
||||
) -> None:
|
||||
"""Send data to redis and delete old data > 5 projections
|
||||
|
||||
Args:
|
||||
data (np.ndarray): Data to send
|
||||
q (np.ndarray): q values
|
||||
norm_sum (np.ndarray): Normalization sum
|
||||
bec_producer (RedisProducer): Redis producer
|
||||
metadata (dict): Metadata
|
||||
proj_nr (int): Projection number
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
keys = _get_projection_keys(bec_producer)
|
||||
pipe = bec_producer.pipeline()
|
||||
proj_numbers = set(key.decode().split("px_stream/projection_")[1].split("/")[0] for key in keys)
|
||||
if len(proj_numbers) > 5:
|
||||
for entry in sorted(proj_numbers)[0:-5]:
|
||||
for key in bec_producer.keys(f"px_stream/projection_{entry}/*"):
|
||||
bec_producer.delete(topic=key, pipe=pipe)
|
||||
print(f"Deleting {key}")
|
||||
|
||||
return_dict = {"metadata": metadata, "q": q, "norm_sum": norm_sum}
|
||||
msg = BECMessage.DeviceMessage(signals=return_dict).dumps()
|
||||
bec_producer.set_and_publish(f"px_stream/projection_{proj_nr}/metadata", msg=msg, pipe=pipe)
|
||||
return_dict = {"proj_nr": proj_nr}
|
||||
msg = BECMessage.DeviceMessage(signals=return_dict).dumps()
|
||||
bec_producer.set_and_publish(f"px_stream/proj_nr", msg=msg, pipe=pipe)
|
||||
pipe.execute()
|
||||
|
||||
for line in range(data.shape[0]):
|
||||
return_dict = {"data": data[line, ...]}
|
||||
msg = BECMessage.DeviceMessage(signals=return_dict).dumps()
|
||||
print(f"Sending line {line}")
|
||||
bec_producer.rpush(topic=f"px_stream/projection_{proj_nr}/data", msgs=msg)
|
||||
print(f"Time to send {time.time()-start} seconds")
|
||||
print(f"Rate {data.shape[0]/(time.time()-start)} Hz")
|
||||
print(f"Data volume {data.nbytes/1e6} MB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Start the stream simulator, defaults to px_stream/projection_* in redis on localhost:6379
|
||||
|
||||
Example usage:
|
||||
>>> python saxs_imaging_streamsimulator.py -d ~/datadir/ -m ~/metadatafile.json -p 180 -d 30 -r localhost:6379
|
||||
"""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--datadir",
|
||||
type=str,
|
||||
help="filepath to datadir for projection files (in h5 format)",
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--metadata",
|
||||
type=str,
|
||||
help="filepath to metadata json file",
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--proj_nr",
|
||||
type=int,
|
||||
help="Projection number matching the data",
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-w",
|
||||
"--wait_delay",
|
||||
type=int,
|
||||
help="delay between sending data in seconds (int)",
|
||||
default=30,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--redis",
|
||||
type=str,
|
||||
help="Redis_host:port",
|
||||
default="localhost:6379",
|
||||
)
|
||||
values = parser.parse_args()
|
||||
data, q, norm_sum, metadata = load_data(datadir=values.datadir, metadata_path=values.metadata)
|
||||
bec_producer = RedisConnector([f"{values.redis}"]).producer()
|
||||
proj_nr = values.proj_nr
|
||||
delay = values.wait_delay
|
||||
while True:
|
||||
send_data(data, q, norm_sum, bec_producer, metadata, proj_nr=proj_nr)
|
||||
time.sleep(delay)
|
||||
bec_producer.delete(topic=f"px_stream/projection_{proj_nr}/data:val")
|
||||
@@ -0,0 +1 @@
|
||||
# Add anything you don't want to check in to git, e.g. very large files
|
||||
@@ -1,11 +0,0 @@
|
||||
# This file is used to select the BEC and Ophyd Devices version for the auto deployment process.
|
||||
# Do not edit this file unless you know what you are doing!
|
||||
|
||||
# The version can be a git tag, branch or commit hash.
|
||||
|
||||
# BEC version to use
|
||||
BEC_AUTODEPLOY_VERSION="master"
|
||||
|
||||
# ophyd_devices version to use
|
||||
OPHYD_DEVICES_AUTODEPLOY_VERSION="master"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
mongodb:
|
||||
host: localhost
|
||||
port: 27017
|
||||
scibec:
|
||||
host: http://[::1]
|
||||
port: 3030
|
||||
beamline: "PXIII"
|
||||
service_config:
|
||||
general:
|
||||
reset_queue_on_cancel: True
|
||||
enforce_ACLs: False
|
||||
file_writer:
|
||||
plugin: default_NeXus_format
|
||||
base_path: ./
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# deployment script to be translated to Ansible
|
||||
|
||||
# can be removed once we have the autodeployment in place
|
||||
BEAMLINE_REPO=gitlab.psi.ch:bec/pxiii-bec.git
|
||||
git clone git@$BEAMLINE_REPO
|
||||
|
||||
module add psi-python38/2020.11
|
||||
|
||||
# start redis
|
||||
docker run --network=host --name redis-bec -d redis
|
||||
# alternative:
|
||||
# conda install -y redis; redis-server &
|
||||
|
||||
|
||||
# get the target versions for ophyd_devices and BEC
|
||||
source ./pxiii-bec/deployment/autodeploy_versions
|
||||
|
||||
git clone -b $OPHYD_DEVICES_AUTODEPLOY_VERSION https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
git clone -b $BEC_AUTODEPLOY_VERSION https://gitlab.psi.ch/bec/bec.git
|
||||
|
||||
# install BEC
|
||||
cd bec
|
||||
source ./bin/install_bec_dev.sh
|
||||
cd ../
|
||||
|
||||
pip install -e ./pxiii-bec
|
||||
|
||||
# start the BEC server
|
||||
bec-server start --config ./pxiii-bec/deployment/bec-server-config.yaml
|
||||
|
||||
+15
-13
@@ -16,31 +16,33 @@ parse the --session argument, add the following lines to the script:
|
||||
|
||||
if args.session == "my_session":
|
||||
print("Loading my_session session")
|
||||
from bec_plugins.bec_client.plugins.my_session import *
|
||||
from bec_plugins.bec_ipython_client.plugins.my_session import *
|
||||
else:
|
||||
print("Loading default session")
|
||||
from bec_plugins.bec_client.plugins.default_session import *
|
||||
from bec_plugins.bec_ipython_client.plugins.default_session import *
|
||||
"""
|
||||
|
||||
# pylint: disable=invalid-name, unused-import, import-error, undefined-variable, unused-variable, unused-argument, no-name-in-module
|
||||
import argparse
|
||||
|
||||
from bec_lib.core import bec_logger
|
||||
from bec_lib import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
logger.info("Using the PXIII startup script.")
|
||||
logger.info("Using the PX-III startup script.")
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--session", help="Session name", type=str, default="cSAXS")
|
||||
args = parser.parse_args()
|
||||
# pylint: disable=import-error
|
||||
_args = _main_dict["args"]
|
||||
|
||||
# SETUP BEAMLINE INFO
|
||||
from bec_client.plugins.SLS.sls_info import OperatorInfo, SLSInfo
|
||||
_session_name = "PX-III"
|
||||
if _args.session.lower() == "alignment":
|
||||
# load the alignment session
|
||||
_session_name = "Alignment"
|
||||
logger.success("Alignment session loaded.")
|
||||
|
||||
bec._beamline_mixin._bl_info_register(SLSInfo)
|
||||
bec._beamline_mixin._bl_info_register(OperatorInfo)
|
||||
|
||||
# SETUP PROMPTS
|
||||
bec._ip.prompts.username = "PXIII"
|
||||
bec._ip.prompts.username = _session_name
|
||||
bec._ip.prompts.status = 1
|
||||
|
||||
d, planner = init_beamline_environment()
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Pre-startup script for BEC client. This script is executed before the BEC client
|
||||
is started. It can be used to add additional command line arguments.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
|
||||
import pxiii_bec
|
||||
|
||||
|
||||
def extend_command_line_args(parser):
|
||||
"""
|
||||
Extend the command line arguments of the BEC client.
|
||||
"""
|
||||
|
||||
parser.add_argument("--session", help="Session name", type=str, default="PX-III")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def get_config() -> ServiceConfig:
|
||||
"""
|
||||
Create and return the ServiceConfig for the plugin repository
|
||||
"""
|
||||
deployment_path = os.path.dirname(os.path.dirname(os.path.dirname(pxiii_bec.__file__)))
|
||||
files = os.listdir(deployment_path)
|
||||
if "bec_config.yaml" in files:
|
||||
return ServiceConfig(config_path=os.path.join(deployment_path, "bec_config.yaml"))
|
||||
else:
|
||||
return ServiceConfig(redis={"host": "localhost", "port": 6379})
|
||||
@@ -0,0 +1,135 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1801</width>
|
||||
<height>1459</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>1801</width>
|
||||
<height>1459</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tab">
|
||||
<attribute name="title">
|
||||
<string>Control Panel</string>
|
||||
</attribute>
|
||||
<layout class="QGridLayout" name="gridLayout" rowstretch="3,4" columnstretch="2,5">
|
||||
<item row="0" column="0">
|
||||
<widget class="Waveform" name="waveform"/>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="ScanControl" name="scan_control"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="ScanHistory" name="scan_history"/>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="BECQueue" name="bec_queue"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_2">
|
||||
<attribute name="title">
|
||||
<string>Logbook</string>
|
||||
</attribute>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>24</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Coming soon...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_3">
|
||||
<attribute name="title">
|
||||
<string>Take a break</string>
|
||||
</attribute>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="Minesweeper" name="minesweeper"/>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>1073</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>ScanControl</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>scan_control</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>Waveform</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>waveform</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECQueue</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_queue</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>Minesweeper</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>minesweeper</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ScanHistory</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>scan_history</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1 @@
|
||||
from .auto_updates import AutoUpdates
|
||||
@@ -0,0 +1,71 @@
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
|
||||
from bec_lib.messages import ScanStatusMessage
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCResponseTimeoutError
|
||||
|
||||
|
||||
class PlotUpdate(AutoUpdates):
|
||||
|
||||
#######################################################################
|
||||
################# GUI Callbacks #######################################
|
||||
#######################################################################
|
||||
|
||||
def on_start(self) -> None:
|
||||
"""
|
||||
Procedure to run when the auto updates are enabled.
|
||||
"""
|
||||
self.start_default_dock()
|
||||
|
||||
def on_stop(self) -> None:
|
||||
"""
|
||||
Procedure to run when the auto updates are disabled.
|
||||
"""
|
||||
|
||||
def on_scan_open(self, msg: ScanStatusMessage) -> None:
|
||||
"""
|
||||
Procedure to run when a scan starts.
|
||||
|
||||
Args:
|
||||
msg (ScanStatusMessage): The scan status message.
|
||||
"""
|
||||
if msg.scan_name == "line_scan" and msg.scan_report_devices:
|
||||
return self.simple_line_scan(msg)
|
||||
if msg.scan_name == "grid_scan" and msg.scan_report_devices:
|
||||
return self.simple_grid_scan(msg)
|
||||
|
||||
dev_x = msg.scan_report_devices[0]
|
||||
if "kwargs" in msg.request_inputs:
|
||||
dev_y = msg.request_inputs["kwargs"].get("plot", None)
|
||||
if dev_y is not None:
|
||||
# Set the dock to the waveform widget
|
||||
wf = self.set_dock_to_widget("Waveform")
|
||||
|
||||
# Clear the waveform widget and plot the data
|
||||
wf.clear_all()
|
||||
wf.plot(
|
||||
x_name=dev_x,
|
||||
y_name=dev_y,
|
||||
label=f"Scan {msg.info.scan_number} - {dev_y}",
|
||||
title=f"Scan {msg.info.scan_number}",
|
||||
x_label=dev_x,
|
||||
y_label=dev_y,
|
||||
)
|
||||
elif msg.scan_report_devices:
|
||||
return self.best_effort(msg)
|
||||
return None
|
||||
|
||||
def on_scan_closed(self, msg: ScanStatusMessage) -> None:
|
||||
"""
|
||||
Procedure to run when a scan ends.
|
||||
|
||||
Args:
|
||||
msg (ScanStatusMessage): The scan status message.
|
||||
"""
|
||||
|
||||
def on_scan_abort(self, msg: ScanStatusMessage) -> None:
|
||||
"""
|
||||
Procedure to run when a scan is aborted.
|
||||
|
||||
Args:
|
||||
msg (ScanStatusMessage): The scan status message.
|
||||
"""
|
||||
@@ -0,0 +1,42 @@
|
||||
# This file was automatically generated by generate_cli.py
|
||||
# type: ignore
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# pylint: skip-file
|
||||
|
||||
|
||||
_Widgets = {
|
||||
"ScanHistory": "ScanHistory",
|
||||
}
|
||||
|
||||
|
||||
class ScanHistory(RPCBase):
|
||||
_IMPORT_MODULE = "pxiii_bec.bec_widgets.widgets.scan_history.scan_history"
|
||||
|
||||
@rpc_call
|
||||
def select_scan_from_history(self, value: "int") -> "None":
|
||||
"""
|
||||
Set scan from CLI.
|
||||
|
||||
Args:
|
||||
value (int) : value from history -1 ...-10000
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def add_scan_from_history(self) -> "None":
|
||||
"""
|
||||
Load selected scan from history.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def clear_plot(self) -> "None":
|
||||
"""
|
||||
Delete all curves on the plot.
|
||||
"""
|
||||
@@ -0,0 +1,13 @@
|
||||
# This file was automatically generated by generate_cli.py
|
||||
# type: ignore
|
||||
from __future__ import annotations
|
||||
|
||||
# pylint: skip-file
|
||||
|
||||
designer_plugins = {
|
||||
"ScanHistory": ("pxiii_bec.bec_widgets.widgets.scan_history.scan_history", "ScanHistory"),
|
||||
}
|
||||
|
||||
widget_icons = {
|
||||
"ScanHistory": "widgets",
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from pxiii_bec.bec_widgets.widgets.scan_history.scan_history_plugin import ScanHistoryPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(ScanHistoryPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtWidgets import QPushButton, QLabel, QSpinBox
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.editors.text_box.text_box import TextBox
|
||||
|
||||
|
||||
class ScanHistoryUIComponents(TypedDict):
|
||||
waveform: Waveform
|
||||
metadata_text_box: TextBox
|
||||
monitor_label: QLabel
|
||||
monitor_combobox: DeviceComboBox
|
||||
history_label: QLabel
|
||||
history_spin_box: QSpinBox
|
||||
history_add: QPushButton
|
||||
history_clear: QPushButton
|
||||
|
||||
|
||||
class ScanHistory(BECWidget, QWidget):
|
||||
USER_ACCESS = ["select_scan_from_history", "add_scan_from_history", "clear_plot"]
|
||||
PLUGIN = True
|
||||
ui_file = "./scan_history.ui"
|
||||
components: ScanHistoryUIComponents
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._load_ui()
|
||||
|
||||
def _load_ui(self):
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader(self).loader(os.path.join(current_path, self.ui_file))
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self.ui)
|
||||
self.setLayout(layout)
|
||||
|
||||
self.components: ScanHistoryUIComponents = {
|
||||
"waveform" : self.ui.waveform,
|
||||
"metadata_text_box" : self.ui.metadata_text_box,
|
||||
"monitor_label" : self.ui.monitor_label,
|
||||
"monitor_combobox" : self.ui.monitor_combobox,
|
||||
"history_label" : self.ui.history_label,
|
||||
"history_spin_box" : self.ui.history_spin_box,
|
||||
"history_add" : self.ui.history_add,
|
||||
"history_clear" : self.ui.history_clear,
|
||||
}
|
||||
|
||||
icon_options = {"size": (16, 16), "convert_to_pixmap": False}
|
||||
|
||||
self.components['monitor_combobox'].apply_filter = False
|
||||
self.components['monitor_combobox'].devices = ['dccm_diode_bottom', 'dccm_diode_top', 'dccm_xbpm', 'ssxbpm', 'xbox_diode']
|
||||
|
||||
self.components['history_spin_box'].setMinimum(-10000)
|
||||
self.components['history_spin_box'].setMaximum(-1)
|
||||
self.components['history_spin_box'].valueChanged.connect(self._scan_history_selected)
|
||||
self._scan_history_selected(-1)
|
||||
self.components['history_spin_box'].setValue(-1)
|
||||
self.components['history_add'].setText("Load")
|
||||
self.components['history_add'].setStyleSheet(
|
||||
"background-color: #129490; color: white; font-weight: bold; font-size: 12px;"
|
||||
)
|
||||
|
||||
self.components['history_clear'].setText("Clear")
|
||||
self.components['history_clear'].setStyleSheet(
|
||||
"background-color: #065143; color: white; font-weight: bold; font-size: 12px;"
|
||||
)
|
||||
|
||||
self.components['history_add'].clicked.connect(self._refresh_plot)
|
||||
self.components['history_clear'].clicked.connect(self.clear_plot)
|
||||
self.setWindowTitle("Scan History")
|
||||
self._scan_history_selected(-1)
|
||||
|
||||
@SafeSlot()
|
||||
def add_scan_from_history(self) -> None:
|
||||
"""Load selected scan from history."""
|
||||
self.components['history_add'].click()
|
||||
|
||||
@SafeSlot()
|
||||
def clear_plot(self) -> None:
|
||||
"""Delete all curves on the plot."""
|
||||
self.components['waveform'].clear_all()
|
||||
|
||||
@SafeSlot()
|
||||
def _refresh_plot(self) -> None:
|
||||
"""Refresh plot."""
|
||||
spin_box_value = self.components['history_spin_box'].value()
|
||||
self._check_scan_in_history(spin_box_value)
|
||||
|
||||
# Get the data from the client
|
||||
data = self.client.history[spin_box_value]
|
||||
|
||||
# Check that the plot does not already have a curve with the same data
|
||||
scan_number = int(data.metadata.bec['scan_number'])
|
||||
monitor_name = self.components['monitor_combobox'].currentText()
|
||||
# Get signal hints
|
||||
signal_name = getattr(self.client.device_manager.devices, monitor_name)._hints
|
||||
signal_name = signal_name[0] if len(signal_name)>0 else signal_name
|
||||
|
||||
curve_label = f"Scan-{scan_number}-{monitor_name}-{signal_name}"
|
||||
if len([curve for curve in self.components['waveform'].curves if curve.config.label == curve_label]):
|
||||
return
|
||||
if not hasattr(data.devices, monitor_name):
|
||||
raise ValueError(f"Device {monitor_name} not found in data.")
|
||||
|
||||
# Get scan motors and check that the plot x_axis motor is the same as the scan motor, if not, clear the plot
|
||||
scan_motors = [motor.decode() for motor in data.metadata.bec['scan_motors']]
|
||||
x_motor_name = self.components['waveform'].x_mode
|
||||
if x_motor_name not in scan_motors:
|
||||
self.clear_plot()
|
||||
self.components['waveform'].x_mode = x_motor_name = scan_motors[0]
|
||||
|
||||
# fetching the data
|
||||
monitor_data = getattr(data.devices, monitor_name).read()[signal_name]['value']
|
||||
motor_data = getattr(data.devices, x_motor_name).read()[x_motor_name]['value']
|
||||
|
||||
# Plot custom curve, with custom label
|
||||
self.components['waveform'].plot(x=motor_data, y=monitor_data, label=curve_label)
|
||||
x_label = f"{x_motor_name} / [{getattr(self.client.device_manager.devices, x_motor_name).egu()}]"
|
||||
self.components['waveform'].x_label = x_label
|
||||
|
||||
def _check_scan_in_history(self, history_value:int) -> None:
|
||||
"""
|
||||
Check if scan is in history.
|
||||
|
||||
Args:
|
||||
history_value (int): Value from history -1...-10000
|
||||
"""
|
||||
if len(self.client.history) < abs(history_value):
|
||||
self.components['metadata_text_box'].set_plain_text(f"Scan history does not have the request scan {history_value} of history with length: {len(self.client.history)}")
|
||||
return
|
||||
|
||||
|
||||
def select_scan_from_history(self, value:int) -> None:
|
||||
"""
|
||||
Set scan from CLI.
|
||||
|
||||
Args:
|
||||
value (int) : value from history -1 ...-10000
|
||||
"""
|
||||
if value >=0:
|
||||
raise ValueError(f"Value must be smaller or equal -1, provided {value}")
|
||||
self.components['history_spin_box'].setValue(value)
|
||||
|
||||
@SafeSlot(int)
|
||||
def _scan_history_selected(self, spin_box_value:int) -> None:
|
||||
self._check_scan_in_history(spin_box_value)
|
||||
data = self.client.history[spin_box_value]
|
||||
data.metadata.bec['scan_motors'][0].decode()
|
||||
|
||||
text = str(data)
|
||||
scan_motor_text = "\n" + "Scan Motors: "
|
||||
for motor in data.metadata.bec['scan_motors']:
|
||||
scan_motor_text += f" {motor.decode()}"
|
||||
|
||||
self.components['metadata_text_box'].set_plain_text(text + scan_motor_text)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _set_x_axis(self, device_x:str) -> None:
|
||||
self.components['waveform'].x_mode = device_x
|
||||
|
||||
@SafeSlot(str)
|
||||
def _plot_new_device(self, device:str) -> None:
|
||||
# if len(curve for curve in self.components["waveform"].curves if curve.config.label == f"{device}-{device}":
|
||||
self.components["waveform"].plot(device)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = ScanHistory()
|
||||
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['scan_history.py']}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>955</width>
|
||||
<height>796</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2" stretch="9,3">
|
||||
<item>
|
||||
<widget class="Waveform" name="waveform">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="monitor_label">
|
||||
<property name="font">
|
||||
<font/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BPM Monitor</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="DeviceComboBox" name="monitor_combobox"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="history_label">
|
||||
<property name="text">
|
||||
<string>Scan History</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="history_spin_box"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="history_add">
|
||||
<property name="text">
|
||||
<string>Add scan</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="history_clear">
|
||||
<property name="text">
|
||||
<string>clear all</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="TextBox" name="metadata_text_box">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>795</width>
|
||||
<height>191</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>TextBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>text_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>DeviceComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>device_combobox</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>Waveform</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>waveform</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from pxiii_bec.bec_widgets.widgets.scan_history.scan_history import ScanHistory
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='ScanHistory' name='scan_history'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class ScanHistoryPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = ScanHistory(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(ScanHistory.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "scan_history"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "ScanHistory"
|
||||
|
||||
def toolTip(self):
|
||||
return "ScanHistory"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
import os
|
||||
|
||||
|
||||
def setup_epics_ca():
|
||||
os.environ["EPICS_CA_AUTO_ADDR_LIST"] = "NO"
|
||||
os.environ["EPICS_CA_ADDR_LIST"] = "129.129.110.255"
|
||||
os.environ["PYTHONIOENCODING"] = "latin1"
|
||||
|
||||
|
||||
def run():
|
||||
setup_epics_ca()
|
||||
@@ -0,0 +1,11 @@
|
||||
import os
|
||||
|
||||
|
||||
def setup_epics_ca():
|
||||
# os.environ["EPICS_CA_AUTO_ADDR_LIST"] = "NO"
|
||||
# os.environ["EPICS_CA_ADDR_LIST"] = "129.129.122.255 sls-x12sa-cagw.psi.ch:5836"
|
||||
os.environ["PYTHONIOENCODING"] = "latin1"
|
||||
|
||||
|
||||
def run():
|
||||
setup_epics_ca()
|
||||
@@ -0,0 +1,211 @@
|
||||
states:
|
||||
robot_sample_exchange:
|
||||
allow_modifiers: true
|
||||
bl_pos: in
|
||||
bl_bright: 'off'
|
||||
bs_pos: in
|
||||
bs_z: safe
|
||||
coll_y: out
|
||||
cryo_pos: in
|
||||
det_cov: 'close'
|
||||
diag_y: out
|
||||
fl_bright: 'off'
|
||||
aerotech_x: in
|
||||
aerotech_y: mount
|
||||
aerotech_z: mount
|
||||
aerotech_u: mount
|
||||
smargon_x: mount
|
||||
smargon_y: mount
|
||||
smargon_chi: mount
|
||||
smargon_phi: mount
|
||||
xrf_pos: out
|
||||
|
||||
sample_alignment:
|
||||
allow_modifiers: true
|
||||
bl_pos: in
|
||||
bl_bright: 'on'
|
||||
bs_pos: in
|
||||
bs_z: safe
|
||||
coll_y: out
|
||||
cryo_pos: in
|
||||
det_cov: 'close'
|
||||
diag_y: out
|
||||
fl_bright: 'on'
|
||||
aerotech_x: in
|
||||
aerotech_y: work
|
||||
aerotech_z: work
|
||||
# aerotech_u: mount
|
||||
# smargon_x: mount
|
||||
# smargon_y: mount
|
||||
# smargon_chi: mount
|
||||
# smargon_phi: mount
|
||||
xrf_pos: out
|
||||
|
||||
data_collection:
|
||||
allow_modifiers: true
|
||||
bl_pos: out
|
||||
bl_bright: 'off'
|
||||
bs_pos: in
|
||||
bs_z: safe
|
||||
coll_y: in
|
||||
cryo_pos: in
|
||||
det_cov: 'open'
|
||||
diag_y: out
|
||||
fl_bright: 'on'
|
||||
aerotech_x: in
|
||||
aerotech_y: work
|
||||
aerotech_z: work
|
||||
# aerotech_u: mount
|
||||
# smargon_x: mount
|
||||
# smargon_y: mount
|
||||
# smargon_chi: mount
|
||||
# smargon_phi: mount
|
||||
xrf_pos: out
|
||||
|
||||
DC_XRF:
|
||||
allow_modifiers: true
|
||||
# bl_pos: out
|
||||
bl_bright: 'off'
|
||||
bs_pos: in
|
||||
bs_z: safe
|
||||
coll_y: in
|
||||
cryo_pos: in
|
||||
det_cov: 'close'
|
||||
diag_y: out
|
||||
fl_bright: 'on'
|
||||
aerotech_x: in
|
||||
aerotech_y: work
|
||||
aerotech_z: work
|
||||
aerotech_u: mount
|
||||
# smargon_x: mount
|
||||
# smargon_y: mount
|
||||
# smargon_chi: mount
|
||||
# smargon_phi: mount
|
||||
xrf_pos: in
|
||||
|
||||
manual_sample_exchange:
|
||||
allow_modifiers: true
|
||||
bl_pos: out
|
||||
bl_bright: 'off'
|
||||
bs_pos: out
|
||||
bs_z: safe
|
||||
coll_y: park
|
||||
cryo_pos: in
|
||||
det_cov: 'close'
|
||||
diag_y: park
|
||||
fl_bright: 'off'
|
||||
aerotech_x: in
|
||||
aerotech_y: mount
|
||||
aerotech_z: mount
|
||||
aerotech_u: mount
|
||||
smargon_x: mount
|
||||
smargon_y: mount
|
||||
smargon_chi: mount
|
||||
smargon_phi: mount
|
||||
xrf_pos: out
|
||||
|
||||
beam_visualisation:
|
||||
bl_pos: out
|
||||
bl_bright: 'off'
|
||||
bs_pos: in
|
||||
bs_z: safe
|
||||
coll_y: out
|
||||
cryo_pos: out
|
||||
det_cov: 'close'
|
||||
diag_y: scint
|
||||
fl_bright: 'off'
|
||||
aerotech_x: out
|
||||
aerotech_y: mount
|
||||
aerotech_z: mount
|
||||
aerotech_u: mount
|
||||
smargon_x: mount
|
||||
smargon_y: mount
|
||||
smargon_chi: mount
|
||||
smargon_phi: mount
|
||||
xrf_pos: out
|
||||
|
||||
flux_measurement:
|
||||
bl_pos: in
|
||||
bl_bright: 'off'
|
||||
bs_pos: in
|
||||
bs_z: safe
|
||||
coll_y: out
|
||||
cryo_pos: out
|
||||
det_cov: 'close'
|
||||
diag_y: i1
|
||||
fl_bright: 'off'
|
||||
aerotech_x: out
|
||||
aerotech_y: mount
|
||||
aerotech_z: mount
|
||||
aerotech_u: mount
|
||||
smargon_x: mount
|
||||
smargon_y: mount
|
||||
smargon_chi: mount
|
||||
smargon_phi: mount
|
||||
xrf_pos: out
|
||||
|
||||
beamstop_alignment:
|
||||
bl_pos: out
|
||||
bl_bright: 'off'
|
||||
bs_pos: in
|
||||
bs_z: samp
|
||||
coll_y: out
|
||||
cryo_pos: out
|
||||
det_cov: 'close'
|
||||
diag_y: out
|
||||
fl_bright: 'on'
|
||||
aerotech_x: out
|
||||
aerotech_y: mount
|
||||
aerotech_z: mount
|
||||
aerotech_u: mount
|
||||
smargon_x: mount
|
||||
smargon_y: mount
|
||||
smargon_chi: mount
|
||||
smargon_phi: mount
|
||||
xrf_pos: out
|
||||
|
||||
maintenance:
|
||||
allow_modifiers: true
|
||||
bl_pos: out
|
||||
bl_bright: 'off'
|
||||
bs_pos: out
|
||||
bs_z: safe
|
||||
coll_y: park
|
||||
cryo_pos: in
|
||||
det_cov: 'close'
|
||||
diag_y: park
|
||||
fl_bright: 'off'
|
||||
aerotech_x: out
|
||||
aerotech_y: mount
|
||||
aerotech_z: mount
|
||||
aerotech_u: mount
|
||||
smargon_x: mount
|
||||
smargon_y: mount
|
||||
smargon_chi: mount
|
||||
smargon_phi: mount
|
||||
xrf_pos: out
|
||||
|
||||
xtal_snapshot:
|
||||
allow_modifiers: true
|
||||
bl_pos: in
|
||||
bl_bright: 'on'
|
||||
bs_pos: in
|
||||
bs_z: safe
|
||||
coll_y: intermediate
|
||||
cryo_pos: in
|
||||
det_cov: 'close'
|
||||
diag_y: out
|
||||
fl_bright: 'on'
|
||||
aerotech_x: in
|
||||
aerotech_y: work
|
||||
aerotech_z: work
|
||||
aerotech_u: mount
|
||||
smargon_x: mount
|
||||
smargon_y: mount
|
||||
smargon_chi: mount
|
||||
smargon_phi: mount
|
||||
xrf_pos: out
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
aerotech_x:
|
||||
userParameter: {"type": continuous, "in": 0.0, "out": -10.0, "safe": -100, "tol": 0.5}
|
||||
aerotech_y:
|
||||
userParameter: {"type": continuous, "mount": 0.0, "work": 0.01, "tol": 0.002}
|
||||
aerotech_z:
|
||||
userParameter: {"type": continuous, "mount": 0.0, "work": 0.02, "tol": 0.01}
|
||||
aerotech_u:
|
||||
userParameter: {"type": continuous, "mount": 0.0}
|
||||
smargon_x:
|
||||
userParameter: {"type": continuous, "mount": 0.0}
|
||||
smargon_y:
|
||||
userParameter: {"type": continuous, "mount": 0.0}
|
||||
smargon_z:
|
||||
userParameter: {"type": continuous, "mount": 0.0}
|
||||
smargon_chi:
|
||||
userParameter: {"type": continuous, "mount": 0.0}
|
||||
smargon_phi:
|
||||
userParameter: {"type": continuous, "mount": 0.0}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
bl_bright:
|
||||
description: Backlight Brightness
|
||||
deviceClass: ophyd.EpicsSignal
|
||||
deviceConfig: {read_pv: 'X06DA-ES-BL:SET', auto_monitor: true}
|
||||
onFailure: buffer
|
||||
enabled: True
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- state
|
||||
readOnly: False
|
||||
softwareTrigger: false
|
||||
userParameter: {"type": continuous, "on": 1.3, "off": 0, “tol”: 0.01}
|
||||
|
||||
bl_pos:
|
||||
description: Backlight Positioner
|
||||
deviceClass: ophyd.EpicsSignal
|
||||
deviceConfig: {read_pv: 'X06DA-ES-BL:POS-SET', auto_monitor: true}
|
||||
onFailure: buffer
|
||||
enabled: True
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- state
|
||||
readOnly: False
|
||||
softwareTrigger: false
|
||||
userParameter: {"type": discrete, "in": 1, "out": 0}
|
||||
|
||||
bs_pos:
|
||||
description: Beamstop Positioner
|
||||
deviceClass: ophyd.EpicsSignal
|
||||
deviceConfig: {read_pv: 'X06DA-ES-BS:POS-SET', auto_monitor: true}
|
||||
onFailure: buffer
|
||||
enabled: True
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- state
|
||||
readOnly: False
|
||||
softwareTrigger: false
|
||||
userParameter: {"type": discrete, "in": 1, "out": 0}
|
||||
|
||||
bs_z:
|
||||
description: Beamstop Z
|
||||
deviceClass: ophyd_devices.EpicsMotor
|
||||
deviceConfig: {prefix: 'X06DA-ES-BS:TRZ'}
|
||||
onFailure: buffer
|
||||
enabled: True
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- state
|
||||
readOnly: False
|
||||
softwareTrigger: false
|
||||
userParameter: {"type": continuous, "min": 13, "samp": 15, "work_min": 20, "safe": 23.8, "max_blin": 24, "max_blout": 35}
|
||||
|
||||
coll_y:
|
||||
description: Collimator Y
|
||||
deviceClass: ophyd_devices.EpicsMotor
|
||||
deviceConfig: {prefix: 'X06DA-ES-COL:TRY'}
|
||||
onFailure: buffer
|
||||
enabled: True
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- state
|
||||
readOnly: False
|
||||
softwareTrigger: false
|
||||
userParameter: {"type": continuous, "in": 40, "out": 20.0, "park": 0,"tol":0.05}
|
||||
|
||||
cryo_pos:
|
||||
description: Cryo Positioner
|
||||
deviceClass: ophyd.EpicsSignal
|
||||
deviceConfig: {read_pv: 'X06DA-ES-CS:POS-SET', auto_monitor: true}
|
||||
onFailure: buffer
|
||||
enabled: True
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- state
|
||||
readOnly: False
|
||||
softwareTrigger: false
|
||||
userParameter: {"type": discrete, "in": 1, "out": 0}
|
||||
|
||||
det_cov:
|
||||
description: Detector Cover
|
||||
deviceClass: ophyd.EpicsSignal
|
||||
deviceConfig: {read_pv: 'X06DA-ES-DETCOV:SET', auto_monitor: true}
|
||||
onFailure: buffer
|
||||
enabled: True
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- state
|
||||
readOnly: False
|
||||
softwareTrigger: false
|
||||
userParameter: {"type": discrete, "open": 2, "close": 1}
|
||||
|
||||
diag_y:
|
||||
description: Scintillator/diode Y
|
||||
deviceClass: ophyd_devices.EpicsMotor
|
||||
deviceConfig: {prefix: 'X06DA-ES-SCL:TRY'}
|
||||
onFailure: buffer
|
||||
enabled: True
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- state
|
||||
readOnly: False
|
||||
softwareTrigger: false
|
||||
userParameter: {"type": continuous, "scint": 25, "i1": 29, "out": 5.0,"park": 0,"tol":0.3}
|
||||
|
||||
fl_bright:
|
||||
description: Frontlight Brightness
|
||||
deviceClass: ophyd.EpicsSignal
|
||||
deviceConfig: {read_pv: 'X06DA-ES-FL:SET', auto_monitor: true}
|
||||
onFailure: buffer
|
||||
enabled: True
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- state
|
||||
readOnly: False
|
||||
softwareTrigger: false
|
||||
userParameter: {"type": continuous, "on": 3.0, "off": 0, “tol”: 0.01}
|
||||
|
||||
xrf_pos:
|
||||
description: XRF Positioner
|
||||
deviceClass: ophyd.EpicsSignal
|
||||
deviceConfig: {read_pv: 'X06DA-ES-XRF:POS-SET', auto_monitor: true}
|
||||
onFailure: buffer
|
||||
enabled: True
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- state
|
||||
readOnly: False
|
||||
softwareTrigger: false
|
||||
userParameter: {"type": discrete, "in": 1, "out": 0}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
base_config:
|
||||
- !include ./pxiii-standard-devices.yaml
|
||||
states_config:
|
||||
- !include ./pxiii-state-devices.yaml
|
||||
|
||||
smargon:
|
||||
description: REST-based device which connects to Smargopolo
|
||||
deviceClass: pxiii_bec.devices.smargopolo_smargon.Smargon
|
||||
deviceConfig: {prefix: 'http://x06da-smargopolo.psi.ch:3000'}
|
||||
onFailure: buffer
|
||||
enabled: True
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- smargon
|
||||
- motors
|
||||
readOnly: false
|
||||
softwareTrigger: false
|
||||
|
||||
aerotech:
|
||||
description: REST-based device which connects to AareScan and Aerotech
|
||||
deviceClass: pxiii_bec.devices.aerotech.Aerotech
|
||||
deviceConfig: {prefix: 'http://mx-x06da-queue-01:5234'}
|
||||
onFailure: buffer
|
||||
enabled: True
|
||||
readoutPriority: baseline
|
||||
deviceTags:
|
||||
- aerotech
|
||||
- motors
|
||||
readOnly: false
|
||||
softwareTrigger: false
|
||||
@@ -0,0 +1,426 @@
|
||||
"""
|
||||
``Aerotech`` --- Aerotech control software
|
||||
******************************************
|
||||
|
||||
This module provides an object to control the Aerotech Abr rotational stage.
|
||||
|
||||
Methods in the Abr class
|
||||
========================
|
||||
|
||||
Standard bluesky interface:
|
||||
AerotechAbrStage.configure(d={...})
|
||||
AerotechAbrStage.kickoff()
|
||||
AerotechAbrStage.stop()
|
||||
Additional bluesky functionality:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Aerotech.is_homed()
|
||||
Aerotech.do_homing(wait=True)
|
||||
Aerotech.get_ready(ostart=None, orange=None, etime=None, wait=True)
|
||||
Aerotech.is_done()
|
||||
Aerotech.is_ready()
|
||||
Aerotech.is_busy()
|
||||
Aerotech.start_exposure()
|
||||
Aerotech.wait_status(status)
|
||||
Aerotech.move(angle, wait=False, speed=None)
|
||||
Aerotech.set_shutter(state)
|
||||
|
||||
|
||||
Returns the axis mode: ``-ES-DF1:AXES-MODE``
|
||||
|
||||
Examples
|
||||
========
|
||||
|
||||
abr = AerotechAbrStage(prefix="X06DA-ES-DF1", name="abr")
|
||||
|
||||
# move omega to 270.0 degrees
|
||||
abr.omega = 270.0
|
||||
|
||||
# move omega to 180 degrees and wait for movement to finish
|
||||
abr.move(180, wait=True)
|
||||
|
||||
# move omega to 3000 degrees at 360 degrees/s and wait for movement to finish
|
||||
abr.move(3000, velocity=360, wait=True)
|
||||
|
||||
# stop any movement
|
||||
abr.stop() # this function only returns after the STATUS is back to OK
|
||||
|
||||
"""
|
||||
|
||||
import time
|
||||
from ophyd import Device, Component, EpicsSignal, EpicsSignalRO, Kind
|
||||
from ophyd.status import SubscriptionStatus
|
||||
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
|
||||
|
||||
try:
|
||||
from .A3200enums import AbrCmd, AbrMode
|
||||
except ImportError:
|
||||
from A3200enums import AbrCmd, AbrMode
|
||||
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# pylint: disable=logging-fstring-interpolation
|
||||
class AerotechAbrStage(PSIDeviceBase, Device):
|
||||
"""Standard PX stage on A3200 controller
|
||||
|
||||
This is the wrapper class for the standard rotation stage layout for the PX
|
||||
beamlines at SLS. It wraps the main rotation axis OMEGA (Aerotech ABR)and
|
||||
the associated motion axes GMX, GMY and GMZ. The ophyd class associates to
|
||||
the general PX measurement procedure, which is that the actual scan script
|
||||
is running as an AeroBasic program on the controller and we communicate to
|
||||
it via 10+1 global variables.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["reset", "kickoff", "complete", "set_axis_mode", "arm", "disarm"]
|
||||
|
||||
taskStop = Component(EpicsSignal, "-AERO:TSK-STOP", put_complete=True, kind=Kind.omitted)
|
||||
status = Component(EpicsSignal, "-AERO:STAT", put_complete=True, kind=Kind.omitted)
|
||||
clear = Component(EpicsSignal, "-AERO:CTRL-CLFT", put_complete=True, kind=Kind.omitted)
|
||||
|
||||
# Enable/disable motor movement via the IOC (i.e. make it task-only)
|
||||
axisModeLocked = Component(EpicsSignal, "-DF1:LOCK", put_complete=True, kind=Kind.omitted)
|
||||
axisModeDirect = Component(
|
||||
EpicsSignal, "-DF1:MODE-DIRECT", put_complete=True, kind=Kind.omitted
|
||||
)
|
||||
axisAxesMode = Component(EpicsSignal, "-DF1:AXES-MODE", put_complete=True, kind=Kind.omitted)
|
||||
|
||||
# Shutter box is missing readback so the -GET signal is installed on the VME
|
||||
# _shutter = Component(
|
||||
# EpicsSignal, "-PH1:GET", write_pv="-PH1:SET", put_complete=True, kind=Kind.config,
|
||||
# )
|
||||
|
||||
# Status flags for all axes
|
||||
omega_done = Component(EpicsSignalRO, "-DF1:OMEGA-DONE", kind=Kind.normal)
|
||||
gmx_done = Component(EpicsSignalRO, "-DF1:GMX-DONE", kind=Kind.normal)
|
||||
gmy_done = Component(EpicsSignalRO, "-DF1:GMY-DONE", kind=Kind.normal)
|
||||
gmz_done = Component(EpicsSignalRO, "-DF1:GMZ-DONE", kind=Kind.normal)
|
||||
|
||||
# For some reason the task interface is called PSO...
|
||||
scan_command = Component(EpicsSignal, "-PSO:CMD", put_complete=True, kind=Kind.omitted)
|
||||
start_command = Component(
|
||||
EpicsSignal, "-PSO:START-TEST.PROC", put_complete=True, kind=Kind.omitted
|
||||
)
|
||||
stop_command = Component(
|
||||
EpicsSignal, "-PSO:STOP-TEST.PROC", put_complete=True, kind=Kind.omitted
|
||||
)
|
||||
|
||||
# Global variables to controll AeroBasic scripts
|
||||
_var_1 = Component(EpicsSignal, "-PSO:VAR-1", put_complete=True, kind=Kind.omitted)
|
||||
_var_2 = Component(EpicsSignal, "-PSO:VAR-2", put_complete=True, kind=Kind.omitted)
|
||||
_var_3 = Component(EpicsSignal, "-PSO:VAR-3", put_complete=True, kind=Kind.omitted)
|
||||
_var_4 = Component(EpicsSignal, "-PSO:VAR-4", put_complete=True, kind=Kind.omitted)
|
||||
_var_5 = Component(EpicsSignal, "-PSO:VAR-5", put_complete=True, kind=Kind.omitted)
|
||||
_var_6 = Component(EpicsSignal, "-PSO:VAR-6", put_complete=True, kind=Kind.omitted)
|
||||
_var_7 = Component(EpicsSignal, "-PSO:VAR-7", put_complete=True, kind=Kind.omitted)
|
||||
_var_8 = Component(EpicsSignal, "-PSO:VAR-8", put_complete=True, kind=Kind.omitted)
|
||||
_var_9 = Component(EpicsSignal, "-PSO:VAR-9", put_complete=True, kind=Kind.omitted)
|
||||
_var_10 = Component(EpicsSignal, "-PSO:VAR-10", put_complete=True, kind=Kind.omitted)
|
||||
|
||||
# Task status PVs (programs always run on task 1)
|
||||
task1 = Component(EpicsSignalRO, "-AERO:TSK1-DONE", auto_monitor=True)
|
||||
task2 = Component(EpicsSignalRO, "-AERO:TSK2-DONE", auto_monitor=True)
|
||||
task3 = Component(EpicsSignalRO, "-AERO:TSK3-DONE", auto_monitor=True)
|
||||
task4 = Component(EpicsSignalRO, "-AERO:TSK4-DONE", auto_monitor=True)
|
||||
scan_done = Component(EpicsSignal, "-GRD:SCAN-DONE", kind=Kind.config)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prefix="",
|
||||
*,
|
||||
name,
|
||||
kind=None,
|
||||
read_attrs=None,
|
||||
configuration_attrs=None,
|
||||
parent=None,
|
||||
scan_info=None,
|
||||
**kwargs,
|
||||
):
|
||||
# super() will call the mixin class
|
||||
super().__init__(
|
||||
prefix=prefix,
|
||||
name=name,
|
||||
kind=kind,
|
||||
read_attrs=read_attrs,
|
||||
configuration_attrs=configuration_attrs,
|
||||
parent=parent,
|
||||
scan_info=scan_info,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def set_axis_mode(self, mode: str, settle_time=0.1) -> None:
|
||||
"""Set axis mode to direct/measurement mode.
|
||||
|
||||
Measurement ensures that the scrips run undisturbed by blocking axis
|
||||
motion commands from the IOC (i.e. internal only).
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
mode : str
|
||||
Valid values are 'direct' and 'measuring'.
|
||||
"""
|
||||
if mode == "direct":
|
||||
self.axisModeDirect.set(37, settle_time=settle_time).wait()
|
||||
if mode == "measuring":
|
||||
self.axisAxesMode.set(AbrMode.MEASURING, settle_time=settle_time).wait()
|
||||
|
||||
def on_stage(self):
|
||||
"""
|
||||
|
||||
NOTE: Zac's request is that stage is essentially ARM, i.e. get ready and don't do anything.
|
||||
"""
|
||||
d = {}
|
||||
# FIXME: I don't care about how we fish out config parameters from scan info
|
||||
scan_args = {
|
||||
**self.scan_info.msg.request_inputs["inputs"],
|
||||
**self.scan_info.msg.request_inputs["kwargs"],
|
||||
**self.scan_info.msg.scan_parameters,
|
||||
}
|
||||
scanname = self.scan_info.msg.scan_name
|
||||
|
||||
if scanname in (
|
||||
"standardscan",
|
||||
"helicalscan",
|
||||
"helicalscan1",
|
||||
"helicalscan2",
|
||||
"helicalscan3",
|
||||
):
|
||||
d["scan_command"] = AbrCmd.MEASURE_STANDARD
|
||||
d["var_1"] = scan_args["start"]
|
||||
d["var_2"] = scan_args["range"]
|
||||
d["var_3"] = scan_args["move_time"]
|
||||
d["var_4"] = scan_args.get("ready_rate", 500)
|
||||
d["var_5"] = 0
|
||||
d["var_6"] = 0
|
||||
d["var_7"] = 0
|
||||
# d["var_8"] = 0
|
||||
# d["var_9"] = 0
|
||||
if scanname in ("verticallinescan", "vlinescan"):
|
||||
d["scan_command"] = AbrCmd.VERTICAL_LINE_SCAN
|
||||
d["var_1"] = scan_args["range"] / scan_args["steps"]
|
||||
d["var_2"] = scan_args["steps"]
|
||||
d["var_3"] = scan_args["exp_time"]
|
||||
d["var_4"] = 0
|
||||
d["var_5"] = 0
|
||||
d["var_6"] = 0
|
||||
d["var_7"] = 0
|
||||
# d["var_8"] = 0
|
||||
# d["var_9"] = 0
|
||||
if scanname in ("screeningscan"):
|
||||
d["scan_command"] = AbrCmd.SCREENING
|
||||
d["var_1"] = scan_args["start"]
|
||||
d["var_2"] = scan_args["oscrange"]
|
||||
d["var_3"] = scan_args["exp_time"]
|
||||
d["var_4"] = scan_args["range"] / scan_args["steps"]
|
||||
d["var_5"] = scan_args["steps"]
|
||||
d["var_6"] = scan_args.get("delta", 0.5)
|
||||
d["var_7"] = 0
|
||||
# d["var_8"] = 0
|
||||
# d["var_9"] = 0
|
||||
if scanname in ("rasterscan", "rastersimplescan"):
|
||||
d["scan_command"] = AbrCmd.RASTER_SCAN_SIMPLE
|
||||
d["var_1"] = scan_args["exp_time"]
|
||||
d["var_2"] = scan_args["range_x"] / scan_args["steps_x"]
|
||||
d["var_3"] = scan_args["range_y"] / scan_args["steps_y"]
|
||||
d["var_4"] = scan_args["steps_x"]
|
||||
d["var_5"] = scan_args["steps_y"]
|
||||
d["var_6"] = 0
|
||||
d["var_7"] = 0
|
||||
# d["var_8"] = 0
|
||||
# d["var_9"] = 0
|
||||
|
||||
# Reconfigure if got a valid scan config
|
||||
if len(d) > 0:
|
||||
self.configure(d)
|
||||
|
||||
# Stage the ABR stage
|
||||
self.arm()
|
||||
|
||||
def on_unstage(self):
|
||||
"""Unstage the ABR controller"""
|
||||
self.disarm()
|
||||
|
||||
def configure(self, d: dict) -> tuple:
|
||||
""" " Configure the exposure scripts
|
||||
|
||||
Script execution at the PX beamlines is based on scripts that are always
|
||||
running on the controller that execute commands when commanded by
|
||||
setting a pre-defined set of global variables. This method performs the
|
||||
configuration of the exposure scrips by setting the required global
|
||||
variables.
|
||||
|
||||
Parameters in d: dict
|
||||
---------------------
|
||||
scan_command: int
|
||||
The index of the desired AeroBasic program to be executed.
|
||||
Usually supported values are taken from an Enum.
|
||||
var_1:
|
||||
var_2:
|
||||
var_3:
|
||||
var_4:
|
||||
var_5:
|
||||
var_6:
|
||||
var_7:
|
||||
var_8:
|
||||
var_9:
|
||||
var_10:
|
||||
"""
|
||||
old = self.read_configuration()
|
||||
|
||||
# ToDo: Check if idle before reconfiguring
|
||||
self.scan_command.set(d["scan_command"]).wait()
|
||||
# Set the corresponding global variables
|
||||
if "var_1" in d and d["var_1"] is not None:
|
||||
self._var_1.set(d["var_1"]).wait()
|
||||
if "var_2" in d and d["var_2"] is not None:
|
||||
self._var_2.set(d["var_2"]).wait()
|
||||
if "var_3" in d and d["var_3"] is not None:
|
||||
self._var_3.set(d["var_3"]).wait()
|
||||
if "var_4" in d and d["var_4"] is not None:
|
||||
self._var_4.set(d["var_4"]).wait()
|
||||
if "var_5" in d and d["var_5"] is not None:
|
||||
self._var_5.set(d["var_5"]).wait()
|
||||
if "var_6" in d and d["var_6"] is not None:
|
||||
self._var_6.set(d["var_6"]).wait()
|
||||
if "var_7" in d and d["var_7"] is not None:
|
||||
self._var_7.set(d["var_7"]).wait()
|
||||
if "var_8" in d and d["var_8"] is not None:
|
||||
self._var_8.set(d["var_8"]).wait()
|
||||
if "var_9" in d and d["var_9"] is not None:
|
||||
self._var_9.set(d["var_9"]).wait()
|
||||
if "var_10" in d and d["var_10"] is not None:
|
||||
self._var_10.set(d["var_10"]).wait()
|
||||
|
||||
new = self.read_configuration()
|
||||
return old, new
|
||||
|
||||
def arm(self):
|
||||
"""Bluesky-style stage
|
||||
|
||||
Since configuration synchronization is not guaranteed, this does
|
||||
nothing. The script launched by kickoff().
|
||||
"""
|
||||
|
||||
def on_kickoff(self, timeout=1) -> SubscriptionStatus:
|
||||
"""Kick off the set program"""
|
||||
self.start_command.set(1).wait()
|
||||
|
||||
# Define wait until the busy flag goes high
|
||||
def is_busy(*, value, **_):
|
||||
return bool(value == 0)
|
||||
|
||||
# Subscribe and wait for update
|
||||
status = SubscriptionStatus(self.scan_done, is_busy, timeout=timeout, settle_time=0.1)
|
||||
status.wait()
|
||||
# return status
|
||||
|
||||
def disarm(self, settle_time=0.1):
|
||||
"""Stops current script and releases the axes"""
|
||||
# Disarm commands
|
||||
self.scan_command.set(AbrCmd.NONE, settle_time=settle_time).wait()
|
||||
self.stop_command.set(1).wait()
|
||||
# Go to direct mode
|
||||
self.set_axis_mode("direct", settle_time=settle_time)
|
||||
|
||||
def complete(self, timeout=None) -> SubscriptionStatus:
|
||||
"""Waits for task execution
|
||||
|
||||
NOTE: Original complete was raster scanner complete...
|
||||
"""
|
||||
|
||||
# Define wait until the busy flag goes down (excluding initial update)
|
||||
def is_idle(*, value, **_):
|
||||
return bool(value == 1)
|
||||
|
||||
# Subscribe and wait for update
|
||||
# status = SubscriptionStatus(self.task1, is_idle, timeout=timeout, settle_time=0.5)
|
||||
status = SubscriptionStatus(self.scan_done, is_idle, timeout=timeout, settle_time=0.5)
|
||||
return status
|
||||
|
||||
def reset(self, settle_time=0.1, wait_after_reload=1) -> None:
|
||||
"""Resets the Aerotech controller state
|
||||
|
||||
Attempts to reset the currently running measurement task on the A3200
|
||||
by stopping current motions, reloading aerobasic programs and going
|
||||
to DIRECT mode.
|
||||
|
||||
This will stop movements in both DIRECT and MEASURING modes. During the
|
||||
stop the `status` temporarely goes to ERROR but reverts to OK after a
|
||||
couple of seconds.
|
||||
"""
|
||||
# Disarm commands
|
||||
self.scan_command.set(AbrCmd.NONE, settle_time=settle_time).wait()
|
||||
self.stop_command.set(1, settle_time=settle_time)
|
||||
# Reload tasks
|
||||
self.taskStop.set(1, settle_time=wait_after_reload).wait()
|
||||
# Go to direct mode
|
||||
self.set_axis_mode("direct", settle_time=settle_time)
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def stop(self, settle_time=1.0) -> None:
|
||||
"""Stops current motions"""
|
||||
# Disarm commands
|
||||
self.scan_command.set(AbrCmd.NONE, settle_time=settle_time).wait()
|
||||
self.stop_command.set(1).wait()
|
||||
# Go to direct mode
|
||||
self.set_axis_mode("direct", settle_time=settle_time)
|
||||
|
||||
def is_ioc_ok(self):
|
||||
"""Checks execution status"""
|
||||
return 0 == self.status.get()
|
||||
|
||||
@property
|
||||
def axis_mode(self):
|
||||
"""Read axis mode"""
|
||||
return self.axisAxesMode.get()
|
||||
|
||||
# @property
|
||||
# def shutter(self):
|
||||
# return self._shutter.get()
|
||||
|
||||
# @shutter.setter
|
||||
# def shutter(self, value):
|
||||
# if self.axisAxesMode.get():
|
||||
# print("ABR is not in direct mode; cannot manipulate shutter")
|
||||
# return False
|
||||
# state = str(state).lower()
|
||||
# if state not in ["1", "0", "closed", "open"]:
|
||||
# print("unknown shutter state requested")
|
||||
# return None
|
||||
# elif state in ["1", "open"]:
|
||||
# state = 1
|
||||
# elif state == ["0", "closed"]:
|
||||
# state = 0
|
||||
# self._shutter.set(state).wait()
|
||||
# return state == self._shutter.get()
|
||||
|
||||
def wait_for_movements(self, timeout=60.0):
|
||||
"""Waits for all motor movements"""
|
||||
t_start = time.time()
|
||||
t_elapsed = 0
|
||||
|
||||
while self.is_moving() and t_elapsed < timeout:
|
||||
t_elapsed = time.time() - t_start
|
||||
if timeout is not None and t_elapsed > timeout:
|
||||
raise TimeoutError("Timeout waiting for all axis to stop moving")
|
||||
time.sleep(0.5)
|
||||
|
||||
def is_moving(self):
|
||||
"""Chechs if all axes are DONE"""
|
||||
return not (
|
||||
self.omega_done.get()
|
||||
and self.gmx_done.get()
|
||||
and self.gmy_done.get()
|
||||
and self.gmz_done.get()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
abr = AerotechAbrStage(prefix="X06DA-ES", name="abr")
|
||||
abr.wait_for_connection()
|
||||
@@ -0,0 +1,74 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Enumerations for the MX specific Aerotech A3200 stage.
|
||||
|
||||
@author: mohacsi_i
|
||||
"""
|
||||
# pylint: disable=too-few-public-methods
|
||||
|
||||
|
||||
class AbrStatus:
|
||||
"""ABR measurement task status"""
|
||||
|
||||
DONE = 0
|
||||
READY = 1
|
||||
BUSY = 2
|
||||
|
||||
|
||||
class AbrGridStatus:
|
||||
"""ABR grid scan status"""
|
||||
|
||||
BUSY = 0
|
||||
DONE = 1
|
||||
|
||||
|
||||
class AbrMode:
|
||||
"""ABR mode status"""
|
||||
|
||||
DIRECT = 0
|
||||
MEASURING = 1
|
||||
|
||||
|
||||
class AbrShutterStatus:
|
||||
"""ABR shutter status"""
|
||||
|
||||
CLOSE = 0
|
||||
OPEN = 1
|
||||
|
||||
|
||||
class AbrCmd:
|
||||
"""ABR command table"""
|
||||
|
||||
NONE = 0
|
||||
RASTER_SCAN_SIMPLE = 1
|
||||
MEASURE_STANDARD = 2
|
||||
VERTICAL_LINE_SCAN = 3
|
||||
SCREENING = 4
|
||||
SUPER_FAST_OMEGA = 5
|
||||
STILL_WEDGE = 6
|
||||
STILLS = 7
|
||||
REPEAT_SINGLE_OSCILLATION = 8
|
||||
SINGLE_OSCILLATION = 9
|
||||
OLD_FASHIONED = 10
|
||||
RASTER_SCAN = 11
|
||||
JET_ROTATION = 12
|
||||
X_HELICAL = 13
|
||||
X_RUNSEQ = 14
|
||||
JUNGFRAU = 15
|
||||
MSOX = 16
|
||||
SLIT_SCAN = 17
|
||||
RASTER_SCAN_STILL = 18
|
||||
SCAN_SASTT = 19
|
||||
SCAN_SASTT_V2 = 20
|
||||
SCAN_SASTT_V3 = 21
|
||||
|
||||
|
||||
class AbrAxis:
|
||||
"""ABR axis index"""
|
||||
|
||||
OMEGA = 1
|
||||
GMX = 2
|
||||
GMY = 3
|
||||
GMZ = 4
|
||||
STY = 5
|
||||
STZ = 6
|
||||
@@ -0,0 +1,259 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Created on Tue Jun 11 11:28:38 2024
|
||||
|
||||
@author: mohacsi_i
|
||||
"""
|
||||
import types
|
||||
from collections import OrderedDict
|
||||
from ophyd import Component, PVPositioner, Signal, EpicsSignal, EpicsSignalRO, Kind, PositionerBase
|
||||
from ophyd.status import Status, MoveStatus
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
from .A3200enums import AbrMode
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# ABR_DONE = 0
|
||||
# ABR_READY = 1
|
||||
# ABR_BUSY = 2
|
||||
|
||||
# GRID_SCAN_BUSY = 0
|
||||
# GRID_SCAN_DONE = 1
|
||||
|
||||
# DIRECT_MODE = 0
|
||||
# MEASURING_MODE = 1
|
||||
|
||||
|
||||
class A3200Axis(PVPositioner):
|
||||
"""Positioner wrapper for A3200 axes
|
||||
|
||||
|
||||
Positioner wrapper for motors on the Aerotech A3200 controller. As the IOC
|
||||
does not provide a motor record, this class simply wraps axes into a
|
||||
standard Ophyd positioner for the BEC. It also has some additional
|
||||
functionality for error checking and diagnostics.
|
||||
|
||||
Examples
|
||||
--------
|
||||
omega = A3200Axis('X06DA-ES-DF1:OMEGA', base_pv='X06DA-ES')
|
||||
|
||||
Parameters
|
||||
----------
|
||||
prefix : str
|
||||
Axis PV name root.
|
||||
base_pv : str (situational)
|
||||
IOC PV name root, i.e. X06DA-ES if standalone class.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["omove"]
|
||||
|
||||
abr_mode_direct = Component(
|
||||
EpicsSignal, "-DF1:MODE-DIRECT", put_complete=True, kind=Kind.omitted
|
||||
)
|
||||
abr_mode = Component(
|
||||
EpicsSignal, "-DF1:AXES-MODE", auto_monitor=True, put_complete=True, kind=Kind.omitted
|
||||
)
|
||||
|
||||
# Basic PV positioner interface
|
||||
done = Component(EpicsSignalRO, "-DONE", auto_monitor=True, kind=Kind.config)
|
||||
readback = Component(EpicsSignalRO, "-RBV", auto_monitor=True, kind=Kind.hinted)
|
||||
# Setpoint is one of the two...
|
||||
setpoint = Component(EpicsSignal, "-SETP", kind=Kind.config)
|
||||
# setpoint = Component(EpicsSignal, "-VAL", kind=Kind.config)
|
||||
|
||||
velocity = Component(EpicsSignal, "-SETV", kind=Kind.config)
|
||||
status = Component(EpicsSignalRO, "-STAT", auto_monitor=True, kind=Kind.config)
|
||||
# PV to issue native relative movements on the A3200
|
||||
relmove = Component(EpicsSignal, "-INCP", put_complete=True, kind=Kind.config)
|
||||
# PV to home axis
|
||||
home = Component(EpicsSignal, "-HOME", kind=Kind.config)
|
||||
ishomed = Component(EpicsSignal, "-AS00", kind=Kind.config)
|
||||
|
||||
# HW status words
|
||||
dshw = Component(EpicsSignalRO, "-DSHW", auto_monitor=True, kind=Kind.normal)
|
||||
ashw = Component(EpicsSignalRO, "-ASHW", auto_monitor=True, kind=Kind.normal)
|
||||
fslw = Component(EpicsSignalRO, "-FSLW", auto_monitor=True, kind=Kind.normal)
|
||||
|
||||
# Rock movement
|
||||
_rock = Component(EpicsSignal, "-ROCK", put_complete=True, kind=Kind.config)
|
||||
_rock_dist = Component(EpicsSignal, "-RINCP", put_complete=True, kind=Kind.config)
|
||||
_rock_velo = Component(EpicsSignal, "-RSETV", put_complete=True, kind=Kind.config)
|
||||
_rock_count = Component(EpicsSignal, "-COUNT", put_complete=True, kind=Kind.config)
|
||||
# _rock_accel = Component(EpicsSignal, "-RRATE", put_complete=True, kind=Kind.config)
|
||||
|
||||
hlm = Component(Signal, kind=Kind.config)
|
||||
llm = Component(Signal, kind=Kind.config)
|
||||
vmin = Component(Signal, kind=Kind.config)
|
||||
vmax = Component(Signal, kind=Kind.config)
|
||||
offset = Component(EpicsSignal, "-OFF", put_complete=True, kind=Kind.config)
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
prefix="",
|
||||
*,
|
||||
name,
|
||||
base_pv="",
|
||||
kind=None,
|
||||
read_attrs=None,
|
||||
configuration_attrs=None,
|
||||
parent=None,
|
||||
llm=0,
|
||||
hlm=0,
|
||||
vmin=0,
|
||||
vmax=0,
|
||||
**kwargs,
|
||||
):
|
||||
"""__init__ MUST have a full argument list"""
|
||||
|
||||
# Patching the parent's PVs into the axis class to check for direct/locked mode
|
||||
if parent is None:
|
||||
|
||||
def maybe_add_prefix(self, _, kw, suffix):
|
||||
# Patched not to enforce parent prefix when no parent
|
||||
if kw in self.add_prefix:
|
||||
return suffix
|
||||
return suffix
|
||||
|
||||
self.__class__.__dict__["abr_mode"].maybe_add_prefix = types.MethodType(
|
||||
maybe_add_prefix, self.__class__.__dict__["abr_mode"]
|
||||
)
|
||||
self.__class__.__dict__["abr_mode_direct"].maybe_add_prefix = types.MethodType(
|
||||
maybe_add_prefix, self.__class__.__dict__["abr_mode_direct"]
|
||||
)
|
||||
logger.info(self.__class__.__dict__["abr_mode"].kwargs)
|
||||
self.__class__.__dict__["abr_mode"].suffix = base_pv + "-DF1:AXES-MODE"
|
||||
self.__class__.__dict__["abr_mode_direct"].suffix = base_pv + "-DF1:MODE-DIRECT"
|
||||
|
||||
super().__init__(
|
||||
prefix=prefix,
|
||||
name=name,
|
||||
kind=kind,
|
||||
read_attrs=read_attrs,
|
||||
configuration_attrs=configuration_attrs,
|
||||
parent=parent,
|
||||
**kwargs,
|
||||
)
|
||||
self.llm.set(llm).wait()
|
||||
self.hlm.set(hlm).wait()
|
||||
self.vmin.set(vmin).wait()
|
||||
self.vmax.set(vmax).wait()
|
||||
|
||||
def omove(
|
||||
self,
|
||||
position,
|
||||
wait=True,
|
||||
timeout=None,
|
||||
# moved_cb=None,
|
||||
velocity=None,
|
||||
relative=False,
|
||||
direct=False,
|
||||
**kwargs,
|
||||
) -> MoveStatus:
|
||||
"""Native absolute/relative movement on the A3200"""
|
||||
|
||||
# Check if we're in direct movement mode
|
||||
if self.abr_mode.value not in (AbrMode.DIRECT, "DIRECT"):
|
||||
if direct:
|
||||
self.abr_mode_direct.set(1).wait()
|
||||
else:
|
||||
raise RuntimeError(f"ABR axis not in direct mode: {self.abr_mode.value}")
|
||||
|
||||
# Before moving, ensure we can stop (if a stop_signal is configured).
|
||||
if self.stop_signal is not None:
|
||||
self.stop_signal.wait_for_connection()
|
||||
|
||||
# Set velocity if provided
|
||||
if velocity is not None:
|
||||
self.velocity.set(velocity).wait()
|
||||
|
||||
# This is adapted from pv_positioner.py
|
||||
self.check_value(position)
|
||||
|
||||
# Get MoveStatus from parent of parent
|
||||
status = PositionerBase.move(self, position=position, timeout=timeout, **kwargs)
|
||||
|
||||
has_done = self.done is not None
|
||||
if not has_done:
|
||||
moving_val = 1 - self.done_value
|
||||
self._move_changed(value=self.done_value)
|
||||
self._move_changed(value=moving_val)
|
||||
|
||||
try:
|
||||
if relative:
|
||||
# Relative movement instead of setpoint
|
||||
self.relmove.put(position, wait=True)
|
||||
else:
|
||||
# Standard absolute movement
|
||||
self.setpoint.put(position, wait=True)
|
||||
if wait:
|
||||
status.wait()
|
||||
except KeyboardInterrupt:
|
||||
self.stop()
|
||||
raise
|
||||
return status
|
||||
|
||||
def describe(self):
|
||||
"""Workaround to schema expected by the BEC"""
|
||||
d = super().describe()
|
||||
d[str(self.name)] = d[f"{self.name}_readback"]
|
||||
return d
|
||||
|
||||
def read(self) -> OrderedDict[str, dict]:
|
||||
"""Workaround to schema expected by the BEC"""
|
||||
d = super().read()
|
||||
d[str(self.name)] = d[f"{self.name}_readback"]
|
||||
return d
|
||||
|
||||
def move(self, position, wait=True, timeout=None, moved_cb=None, **kwargs) -> MoveStatus:
|
||||
"""Exposes the ophyd move command through BEC abstraction"""
|
||||
return self.omove(position, wait=wait, timeout=timeout, moved_cb=moved_cb, **kwargs)
|
||||
|
||||
def rock(self, distance, counts: int, velocity=None, wait=True) -> Status:
|
||||
"""Repeated single axis zigzag scan I guess PSO should be configured for this"""
|
||||
|
||||
self._rock_dist.put(distance)
|
||||
self._rock_count.put(counts)
|
||||
if velocity is not None:
|
||||
self._rock_velo.put(velocity)
|
||||
# if acceleration is not None:
|
||||
# self._rock_accel.put(acceleration)
|
||||
self._rock.put(1)
|
||||
|
||||
status = super().move(position=distance)
|
||||
if wait:
|
||||
status.wait()
|
||||
return status
|
||||
|
||||
# def is_omega_ok(self):
|
||||
# """Checks omega axis status"""
|
||||
# return 0 == self.self.omega.status.get()
|
||||
|
||||
# def is_homed(self):
|
||||
# """Checks if omega is homed"""
|
||||
# return 1 == self.omega.is_homed.get()
|
||||
|
||||
# def do_homing(self, wait=True):
|
||||
# """Execute the homing procedure.
|
||||
|
||||
# Executes the homing procedure on omega and waits (default) until it is completed.
|
||||
|
||||
# TODO: Return a status object to do this wwith futures and monitoring.
|
||||
|
||||
# PARAMETERS
|
||||
# `wait` true / false if the routine is to wait for the homing to finish.
|
||||
# """
|
||||
# self.omega.home.set(1, settle_time=1).wait()
|
||||
# if not wait:
|
||||
# return
|
||||
# while not self.omega.is_homed():
|
||||
# time.sleep(0.2)
|
||||
|
||||
|
||||
# Automatically start an axis if directly invoked
|
||||
if __name__ == "__main__":
|
||||
omega = A3200Axis(prefix="X06DA-ES-DF1:OMEGA", base_pv="X06DA-ES", name="omega")
|
||||
omega.wait_for_connection()
|
||||
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
``NDArrayPreview`` --- Standalone Preview for ImagePlugin
|
||||
*********************************************************
|
||||
|
||||
This module provides a standalone object to receive images to ophyd from the
|
||||
AreaDetector's ImagePlugin.
|
||||
|
||||
Created on Wed Jan 29 2025
|
||||
|
||||
@author: mohacsi_i
|
||||
"""
|
||||
import numpy as np
|
||||
from ophyd import Device, Component, EpicsSignal, EpicsSignalWithRBV, Kind, Staged
|
||||
from ophyd.areadetector import NDDerivedSignal
|
||||
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class SilentNDDerivedSignal(NDDerivedSignal):
|
||||
"""Silent version of NDDerivedSignal, it does not spam the terminal on
|
||||
every defective frame (shit happens, ok?)."""
|
||||
|
||||
def _array_shape_callback(self, **kwargs):
|
||||
try:
|
||||
super()._array_shape_callback(**kwargs)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
class NDArrayPreview(Device):
|
||||
"""Wrapper class around AreaDetector's NDStdArray plugins
|
||||
|
||||
This is a standalone class to display images from AreaDetector's
|
||||
ImagePlugin without using a parent device. It also offers BEC exposed
|
||||
methods to transfer image and change image array Kind-ness.
|
||||
|
||||
NOTE: As an explicit request, it can toggle data recording
|
||||
"""
|
||||
|
||||
# Subscriptions for plotting image
|
||||
USER_ACCESS = ["image", "savemode"]
|
||||
SUB_MONITOR = "device_monitor_2d"
|
||||
_default_sub = SUB_MONITOR
|
||||
|
||||
# Status attributes
|
||||
min_callback_time = Component(
|
||||
EpicsSignalWithRBV, "MinCallbackTime", kind=Kind.config, put_complete=True
|
||||
)
|
||||
array_size_x = Component(EpicsSignal, "ArraySize0_RBV", kind=Kind.config)
|
||||
array_size_y = Component(EpicsSignal, "ArraySize1_RBV", kind=Kind.config)
|
||||
array_size_z = Component(EpicsSignal, "ArraySize2_RBV", kind=Kind.config)
|
||||
ndimensions = Component(EpicsSignal, "NDimensions_RBV", kind=Kind.config)
|
||||
array_data = Component(EpicsSignal, "ArrayData", kind=Kind.omitted)
|
||||
shaped_image = Component(
|
||||
SilentNDDerivedSignal,
|
||||
derived_from="array_data",
|
||||
shape=("array_size_z", "array_size_y", "array_size_x"),
|
||||
num_dimensions="ndimensions",
|
||||
kind=Kind.omitted,
|
||||
)
|
||||
|
||||
def read(self):
|
||||
"""Stream out data on every read()"""
|
||||
if self._staged == Staged.yes:
|
||||
image = self.shaped_image.get()
|
||||
self._run_subs(sub_type=self.SUB_MONITOR, value=image)
|
||||
return super().read()
|
||||
|
||||
def savemode(self, save=False):
|
||||
"""Toggle save mode for the shaped image"""
|
||||
# pylint: disable=protected-access
|
||||
if save:
|
||||
self.shaped_image._kind = Kind.normal
|
||||
else:
|
||||
self.shaped_image._kind = Kind.omitted
|
||||
|
||||
def image(self):
|
||||
"""Fallback method in case image streaming fills up the BEC"""
|
||||
array_size = (self.array_size_z.get(), self.array_size_y.get(), self.array_size_x.get())
|
||||
if array_size == (0, 0, 0):
|
||||
raise RuntimeError("Invalid image; ensure array_callbacks are on")
|
||||
|
||||
if array_size[-1] == 0:
|
||||
array_size = array_size[:-1]
|
||||
|
||||
image = self.array_data.get()
|
||||
return np.array(image).reshape(array_size)
|
||||
|
||||
|
||||
# Automatically connect to SAMCAM at PXIII if directly invoked
|
||||
if __name__ == "__main__":
|
||||
img = NDArrayPreview("X06DA-SAMCAM:image1:", name="samimg")
|
||||
img.wait_for_connection()
|
||||
@@ -0,0 +1,47 @@
|
||||
from ophyd import EpicsSignal
|
||||
from ophyd.status import SubscriptionStatus
|
||||
|
||||
|
||||
class PneumaticValve(EpicsSignal):
|
||||
"""Wrapper around EpicsSignal to wait until reaching target. Use the
|
||||
status returned by set() to wait until movement is finished. Do NOT
|
||||
use put if you want to wait, that's a low-level PV write op.
|
||||
|
||||
NOTE: The SET and GET states do not match exactly
|
||||
"""
|
||||
|
||||
def set(self, value, *, timeout=5, settle_time=0.1):
|
||||
"""Overloaded setter that waits for target state
|
||||
|
||||
NOTE: The SubscriptionStatus callback does not run in put()
|
||||
"""
|
||||
# Lazy hardcoded state lookup
|
||||
target = 1 if value in (1, "Measure") else 2
|
||||
|
||||
# Define wait until an end state is reached
|
||||
def on_target(*, value, **_):
|
||||
return bool(value == target)
|
||||
|
||||
# Subscribe a monitor in advance and wait for update
|
||||
status = SubscriptionStatus(self, on_target, timeout=timeout, settle_time=0.1)
|
||||
# Set value to start movement
|
||||
super().set(value, settle_time=settle_time).wait()
|
||||
# Return the monitor
|
||||
return status
|
||||
|
||||
def check_value(self, value):
|
||||
"""Input validation"""
|
||||
if value not in (0, 1, "Measure", "Park"):
|
||||
raise ValueError(f"Unsupported pneumatic valve target {value}")
|
||||
return super().check_value(value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pneum = PneumaticValve(
|
||||
read_pv="X06DA-ES-BS:GET-POS",
|
||||
write_pv="X06DA-ES-BS:SET-POS",
|
||||
auto_monitor=True,
|
||||
put_complete=True,
|
||||
name="bspump",
|
||||
)
|
||||
pneum.wait_for_connection()
|
||||
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
``SamCam`` --- Sample Camera control software
|
||||
*********************************************
|
||||
|
||||
This module provides an object to control the sample camera at the PX III
|
||||
beamline. The camera should run continously and stream data via ZMQ for
|
||||
the GUI and alignment scripts.
|
||||
|
||||
Created on Thu Jan 30 2025
|
||||
|
||||
@author: mohacsi_i
|
||||
"""
|
||||
from ophyd import ADComponent
|
||||
from ophyd_devices.devices.areadetector.cam import GenICam
|
||||
|
||||
# from ophyd_devices.devices.areadetector.plugins import ImagePlugin_V35
|
||||
from ophyd_devices.interfaces.base_classes.psi_detector_base import (
|
||||
PSIDetectorBase,
|
||||
CustomDetectorMixin,
|
||||
)
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class SamCamSetup(CustomDetectorMixin):
|
||||
"""Simple camera mixin class, the SAMCAM is usually streaming"""
|
||||
|
||||
def on_stage(self):
|
||||
"""Just make sure it's running continously"""
|
||||
self.parent.cam.acquire.put(1, wait=True)
|
||||
|
||||
def on_unstage(self):
|
||||
"""Should run continously"""
|
||||
|
||||
def on_stop(self):
|
||||
"""Should run continously"""
|
||||
|
||||
|
||||
class SamCamDetector(PSIDetectorBase):
|
||||
"""Sample camera device
|
||||
|
||||
The SAMCAM continously streams images to the GUI and sample alignment
|
||||
scripts via ZMQ.
|
||||
"""
|
||||
|
||||
custom_prepare_cls = SamCamSetup
|
||||
|
||||
cam = ADComponent(GenICam, "cam1:")
|
||||
# image = ADComponent(ImagePlugin_V35, "image1:")
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
``SmarGon`` --- SmarGon control software
|
||||
******************************************
|
||||
|
||||
The module provides an object to control the SmarGon goniometer axes at PX III.
|
||||
The SmarGon axes are interfaced as positioners.
|
||||
"""
|
||||
|
||||
import time
|
||||
from threading import Thread, Lock
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter, Retry
|
||||
from collections import OrderedDict
|
||||
from ophyd import Component, Kind, Signal, PVPositioner
|
||||
from ophyd.status import SubscriptionStatus
|
||||
|
||||
try:
|
||||
from bec_lib import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
except ModuleNotFoundError:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("SmarGon")
|
||||
|
||||
|
||||
# SmarGon contoller can't really handle multiple connections
|
||||
# Use this mutex to ensure one access at a time
|
||||
mutex = Lock()
|
||||
|
||||
|
||||
class SmarGonSignal(Signal):
|
||||
"""SmarGonSignal (R/W)
|
||||
|
||||
Small helper class to read/write parameters from SmarGon. As there is no
|
||||
motion status readback from smargopolo, this should be substituted with
|
||||
setting with 'settle_time'.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, write_addr="targetSCS", low_limit=None, high_limit=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.write_addr = write_addr
|
||||
self.addr = self.parent.name
|
||||
self._limits = (low_limit, high_limit)
|
||||
# self.get()
|
||||
|
||||
def put(self, value, *, timestamp=None, **kwargs):
|
||||
"""Overriden put to add communication with smargopolo"""
|
||||
# Validate new value and get timestamp
|
||||
self.check_value(value)
|
||||
if timestamp is None:
|
||||
timestamp = time.time()
|
||||
|
||||
# Perform the actual write to SmargoPolo
|
||||
# pylint: disable=protected-access
|
||||
r = self.parent._go_n_put(f"{self.write_addr}?{self.addr.upper()}={value}")
|
||||
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
old_value = self._readback
|
||||
self._timestamp = timestamp
|
||||
self._readback = r[self.addr.upper()]
|
||||
self._value = r[self.addr.upper()]
|
||||
|
||||
# Notify subscribers
|
||||
self._run_subs(
|
||||
sub_type=self.SUB_VALUE, old_value=old_value, value=value, timestamp=self._timestamp
|
||||
)
|
||||
|
||||
@property
|
||||
def limits(self):
|
||||
return self._limits
|
||||
|
||||
def check_value(self, value, **kwargs):
|
||||
"""Check if value falls within limits"""
|
||||
lol = self.limits[0]
|
||||
if lol is not None:
|
||||
if value < lol:
|
||||
raise ValueError(f"Target {value} outside of limits {self.limits}")
|
||||
hil = self.limits[1]
|
||||
if hil is not None:
|
||||
if value > hil:
|
||||
raise ValueError(f"Target {value} outside of limits {self.limits}")
|
||||
|
||||
def get(self, **kwargs):
|
||||
# pylint: disable=protected-access
|
||||
r = self.parent._go_n_get(self.write_addr)
|
||||
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self._value = r[self.addr.upper()] if isinstance(r, dict) else r
|
||||
return super().get(**kwargs)
|
||||
|
||||
|
||||
class SmarGonSignalRO(Signal):
|
||||
"""Small helper class for read-only parameters PVs from SmarGon.
|
||||
|
||||
Reads and optionally monitors a variable on the SmarGon.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, *args, read_addr="readbackSCS", auto_monitor=False, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._metadata["write_access"] = False
|
||||
self.read_addr = read_addr
|
||||
self.addr = self.parent.name
|
||||
|
||||
if auto_monitor:
|
||||
self._mon = Thread(target=self.poll, daemon=True)
|
||||
self._mon.start()
|
||||
|
||||
def get(self, **kwargs):
|
||||
# pylint: disable=protected-access
|
||||
r = self.parent._go_n_get(self.read_addr)
|
||||
|
||||
if isinstance(r, dict):
|
||||
self.put(r[self.addr.upper()], force=True)
|
||||
else:
|
||||
self.put(r, force=True)
|
||||
return self._readback
|
||||
|
||||
def poll(self):
|
||||
"""Fooo"""
|
||||
time.sleep(2)
|
||||
while True:
|
||||
time.sleep(0.25)
|
||||
try:
|
||||
self.get()
|
||||
except requests.ConnectTimeout as ex:
|
||||
logger.error(f"[{self.name}] {ex}")
|
||||
|
||||
|
||||
class SmarGonAxis(PVPositioner):
|
||||
"""SmarGon client deice
|
||||
|
||||
This class controls the SmarGon goniometer via the REST interface. All
|
||||
SmarGon axes share a common mutex to manage actual HW access.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["omove"]
|
||||
|
||||
# Status attributes
|
||||
sg_url = Component(Signal, kind=Kind.config, metadata={"write_access": False})
|
||||
corr = Component(SmarGonSignalRO, read_addr="corr_type", kind=Kind.config)
|
||||
mode = Component(SmarGonSignalRO, read_addr="mode", kind=Kind.config)
|
||||
|
||||
# Axis parameters
|
||||
readback = Component(SmarGonSignalRO, kind=Kind.hinted, auto_monitor=True)
|
||||
setpoint = Component(SmarGonSignal, kind=Kind.normal)
|
||||
done = Component(Signal, value=1, kind=Kind.normal)
|
||||
# moving = Component(SmarGonMovingSignalRO, kind=Kind.config)
|
||||
_tol = 0.001
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
prefix="SCS",
|
||||
*,
|
||||
name,
|
||||
kind=None,
|
||||
read_attrs=None,
|
||||
configuration_attrs=None,
|
||||
parent=None,
|
||||
sg_url: str = "http://x06da-smargopolo.psi.ch:3000",
|
||||
low_limit=None,
|
||||
high_limit=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.__class__.__dict__["readback"].kwargs["read_addr"] = f"readback{prefix}"
|
||||
self.__class__.__dict__["setpoint"].kwargs["write_addr"] = f"target{prefix}"
|
||||
self.__class__.__dict__["setpoint"].kwargs["low_limit"] = low_limit
|
||||
self.__class__.__dict__["setpoint"].kwargs["high_limit"] = high_limit
|
||||
self.__class__.__dict__["sg_url"].kwargs["value"] = sg_url
|
||||
# Fine-tune HTTP connection behavior
|
||||
# NOTE: SmarGon has a few failed requests every one in a while
|
||||
self._s = requests.Session()
|
||||
retries = Retry(total=5, backoff_factor=0.05, status_forcelist=[500, 502, 503, 504])
|
||||
self._s.mount("http://", HTTPAdapter(max_retries=retries))
|
||||
|
||||
super().__init__(
|
||||
prefix=prefix,
|
||||
name=name,
|
||||
kind=kind,
|
||||
read_attrs=read_attrs,
|
||||
configuration_attrs=configuration_attrs,
|
||||
parent=parent,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def initialize(self):
|
||||
"""Helper function for initial readings"""
|
||||
# self.corr.get()
|
||||
# self.mode.get()
|
||||
r = self._go_n_get("corr_type")
|
||||
print(r)
|
||||
|
||||
def move(self, position, wait=True, timeout=None, moved_cb=None):
|
||||
"""Move command that's masked by BEC"""
|
||||
return self.omove(position, wait, timeout, moved_cb)
|
||||
|
||||
def omove(self, position, wait=True, timeout=2.0, moved_cb=None):
|
||||
"""Original move command without the BEC wrappers"""
|
||||
status = self.setpoint.set(position, settle_time=0.1)
|
||||
status.wait()
|
||||
if not wait:
|
||||
return status
|
||||
|
||||
def on_target(*, value, **_):
|
||||
distance = abs(value - self.setpoint._value)
|
||||
print(f"[self.name] Distance: {distance}")
|
||||
return bool(distance < self._tol)
|
||||
|
||||
status = SubscriptionStatus(self.readback, on_target, timeout=timeout, settle_time=0.1)
|
||||
return status
|
||||
|
||||
def describe(self):
|
||||
"""Workaround to schema expected by the BEC"""
|
||||
d = super().describe()
|
||||
d[str(self.name)] = d[f"{self.name}_readback"]
|
||||
return d
|
||||
|
||||
def read(self) -> OrderedDict[str, dict]:
|
||||
"""Workaround to schema expected by the BEC"""
|
||||
d = super().read()
|
||||
d[str(self.name)] = d[f"{self.name}_readback"]
|
||||
return d
|
||||
|
||||
def _pos_changed(self, timestamp=None, value=None, **kwargs):
|
||||
pass
|
||||
|
||||
def _go_n_get(self, address, **kwargs):
|
||||
"""Helper function to connect to smargopolo"""
|
||||
cmd = f"{self.sg_url.get()}/{address}"
|
||||
try:
|
||||
with mutex:
|
||||
r = self._s.get(cmd, timeout=1, **kwargs)
|
||||
except TimeoutError:
|
||||
try:
|
||||
time.sleep(0.05)
|
||||
with mutex:
|
||||
r = self._s.get(cmd, timeout=0.5, **kwargs)
|
||||
except TimeoutError:
|
||||
time.sleep(0.05)
|
||||
with mutex:
|
||||
r = self._s.get(cmd, timeout=0.5, **kwargs)
|
||||
if not r.ok:
|
||||
raise RuntimeError(
|
||||
f"[{self.name}] Error getting {address}; reply was {r.status_code} => {r.reason}"
|
||||
)
|
||||
return r.json()
|
||||
|
||||
def _go_n_put(self, address, **kwargs):
|
||||
"""Helper function to connect to smargopolo"""
|
||||
cmd = f"{self.sg_url.get()}/{address}"
|
||||
try:
|
||||
with mutex:
|
||||
r = self._s.put(cmd, timeout=1, **kwargs)
|
||||
except TimeoutError:
|
||||
try:
|
||||
time.sleep(0.05)
|
||||
with mutex:
|
||||
r = self._s.put(cmd, timeout=0.5, **kwargs)
|
||||
except TimeoutError:
|
||||
time.sleep(0.05)
|
||||
with mutex:
|
||||
r = self._s.put(cmd, timeout=0.5, **kwargs)
|
||||
if not r.ok:
|
||||
raise RuntimeError(
|
||||
f"[{self.name}] Error putting {address}; reply was {r.status_code} => {r.reason}"
|
||||
)
|
||||
return r.json()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
shx = SmarGonAxis(prefix="SCS", name="shx", sg_url="http://x06da-smargopolo.psi.ch:3000")
|
||||
shy = SmarGonAxis(prefix="SCS", name="shy", sg_url="http://x06da-smargopolo.psi.ch:3000")
|
||||
shz = SmarGonAxis(
|
||||
prefix="SCS",
|
||||
name="shz",
|
||||
low_limit=10,
|
||||
high_limit=22,
|
||||
sg_url="http://x06da-smargopolo.psi.ch:3000",
|
||||
)
|
||||
shx.wait_for_connection()
|
||||
shy.wait_for_connection()
|
||||
shz.wait_for_connection()
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
``SmarGon`` --- SmarGon control software
|
||||
******************************************
|
||||
|
||||
The module provides an object to control the SmarGon goniometer axes at PX III.
|
||||
The SmarGon axes are interfaced as positioners.
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter, Retry
|
||||
from ophyd import Component, Kind, Signal, PVPositioner
|
||||
from ophyd.status import SubscriptionStatus
|
||||
|
||||
try:
|
||||
from bec_lib import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
except ModuleNotFoundError:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("SmarGon")
|
||||
|
||||
|
||||
# SmarGon contoller can't really handle multiple connections
|
||||
# Use this mutex to ensure one access at a time
|
||||
mutex = threading.Lock()
|
||||
|
||||
|
||||
class LimitedSmarGonSignal(Signal):
|
||||
"""SmarGonSignal (R/W)
|
||||
|
||||
Small helper class to read/write parameters from SmarGon. As there is no
|
||||
motion status readback from smargopolo, this should be substituted with
|
||||
setting with 'settle_time'.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, write_addr="targetSCS", low_limit=None, high_limit=None, **kwargs):
|
||||
self._limits = (low_limit, high_limit)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.write_addr = write_addr
|
||||
|
||||
@property
|
||||
def limits(self):
|
||||
return self._limits
|
||||
|
||||
def check_value(self, value, **kwargs):
|
||||
"""Check if value falls within limits"""
|
||||
lol = self.limits[0]
|
||||
if lol is not None:
|
||||
if value < lol:
|
||||
raise ValueError(f"Target {value} outside of limits {self.limits}")
|
||||
hil = self.limits[1]
|
||||
if hil is not None:
|
||||
if value > hil:
|
||||
raise ValueError(f"Target {value} outside of limits {self.limits}")
|
||||
|
||||
def put(self, value, *, timestamp=None, force=False, metadata=None, **kwargs):
|
||||
"""Overriden put to add communication with smargopolo"""
|
||||
# Validate new value and get timestamp
|
||||
if not force:
|
||||
self.check_value(value)
|
||||
if timestamp is None:
|
||||
timestamp = time.time()
|
||||
|
||||
# Perform the actual write to SmargoPolo
|
||||
# pylint: disable=protected-access
|
||||
r = self.parent._go_n_put(f"{self.write_addr}?{self.parent.name.upper()}={value}")
|
||||
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
old_value = self._readback
|
||||
self._timestamp = timestamp
|
||||
self._readback = r[self.parent.name.upper()]
|
||||
self._value = r[self.parent.name.upper()]
|
||||
|
||||
# Notify subscribers
|
||||
self._run_subs(
|
||||
sub_type=self.SUB_VALUE, old_value=old_value, value=value, timestamp=self._timestamp
|
||||
)
|
||||
|
||||
|
||||
class SmarGonAxis(PVPositioner):
|
||||
"""SmarGon client deice
|
||||
|
||||
This class controls the SmarGon goniometer via the REST interface. All
|
||||
SmarGon axes share a common mutex to manage actual HW access.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["omove", "oldmove"]
|
||||
|
||||
# Status attributes
|
||||
sg_url = Component(Signal, kind=Kind.config, metadata={"write_access": False})
|
||||
|
||||
# Axis parameters
|
||||
readback = Component(Signal, kind=Kind.hinted, metadata={"write_access": False})
|
||||
setpoint = Component(LimitedSmarGonSignal, kind=Kind.normal)
|
||||
done = Component(Signal, value=1, kind=Kind.normal, metadata={"write_access": False})
|
||||
_tol = 0.001
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
prefix="SCS",
|
||||
*,
|
||||
name,
|
||||
kind=None,
|
||||
read_attrs=None,
|
||||
configuration_attrs=None,
|
||||
parent=None,
|
||||
sg_url: str = "http://x06da-smargopolo.psi.ch:3000",
|
||||
low_limit=None,
|
||||
high_limit=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
# self.__class__.__dict__["setpoint"].kwargs["write_addr"] = f"target{prefix}"
|
||||
self.__class__.__dict__["setpoint"].kwargs["low_limit"] = low_limit
|
||||
self.__class__.__dict__["setpoint"].kwargs["high_limit"] = high_limit
|
||||
self.__class__.__dict__["sg_url"].kwargs["value"] = sg_url
|
||||
# Fine-tune HTTP connection behavior
|
||||
# NOTE: SmarGon has a few failed requests every one in a while
|
||||
self._s = requests.Session()
|
||||
retries = Retry(total=5, backoff_factor=0.05, status_forcelist=[500, 502, 503, 504])
|
||||
self._s.mount("http://", HTTPAdapter(max_retries=retries))
|
||||
|
||||
super().__init__(
|
||||
prefix=prefix,
|
||||
name=name,
|
||||
kind=kind,
|
||||
read_attrs=read_attrs,
|
||||
configuration_attrs=configuration_attrs,
|
||||
parent=parent,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def on_target():
|
||||
"""Monitors the setpoint and readback and calculates the on_target flag"""
|
||||
time.sleep(2)
|
||||
while True:
|
||||
# Read back target and setpoint values
|
||||
# pylint: disable=protected-access
|
||||
r = self._go_n_get("readbackSCS")
|
||||
rb = r[self.name.upper()]
|
||||
self.readback.set(rb, force=True).wait()
|
||||
r = self._go_n_get("targetSCS")
|
||||
sp = r[self.name.upper()]
|
||||
self.setpoint._value = sp
|
||||
# print(f"Readback: {rb}\tSetpoint: {sp}")
|
||||
# Check if they're within tolerance
|
||||
distance = abs(rb - sp)
|
||||
done = 1 if distance < self._tol else 0
|
||||
self.done.put(done, force=True)
|
||||
time.sleep(0.2)
|
||||
|
||||
self._mon = threading.Thread(target=on_target, daemon=True)
|
||||
self._mon.start()
|
||||
|
||||
def omove(self, position, wait=True, timeout=None, moved_cb=None):
|
||||
"""Move command that's masked by BEC"""
|
||||
self.done.put(0, force=True)
|
||||
return self.move(position, wait, timeout, moved_cb)
|
||||
|
||||
def oldmove(self, position, wait=True, timeout=2.0, moved_cb=None):
|
||||
"""Original move command without the BEC wrappers"""
|
||||
status = self.setpoint.set(position, settle_time=0.1).wait()
|
||||
if not wait:
|
||||
return status
|
||||
|
||||
def on_target(*, value, **_):
|
||||
distance = abs(value - self.setpoint._value)
|
||||
print(f"[self.name] Distance: {distance}")
|
||||
return bool(distance < self._tol)
|
||||
|
||||
status = SubscriptionStatus(self.readback, on_target, timeout=timeout, settle_time=0.1)
|
||||
return status
|
||||
|
||||
def describe(self):
|
||||
"""Workaround to schema expected by the BEC"""
|
||||
d = super().describe()
|
||||
d[str(self.name)] = d[f"{self.name}_readback"]
|
||||
return d
|
||||
|
||||
def read(self) -> OrderedDict[str, dict]:
|
||||
"""Workaround to schema expected by the BEC"""
|
||||
d = super().read()
|
||||
d[str(self.name)] = d[f"{self.name}_readback"]
|
||||
return d
|
||||
|
||||
def _pos_changed(self, timestamp=None, value=None, **kwargs):
|
||||
"""Remove EPICS dependency"""
|
||||
pass
|
||||
|
||||
def _go_n_get(self, address, **kwargs):
|
||||
"""Helper function to connect to smargopolo"""
|
||||
cmd = f"{self.sg_url.get()}/{address}"
|
||||
try:
|
||||
with mutex:
|
||||
r = self._s.get(cmd, timeout=1, **kwargs)
|
||||
except TimeoutError:
|
||||
try:
|
||||
time.sleep(0.05)
|
||||
with mutex:
|
||||
r = self._s.get(cmd, timeout=0.5, **kwargs)
|
||||
except TimeoutError:
|
||||
time.sleep(0.05)
|
||||
with mutex:
|
||||
r = self._s.get(cmd, timeout=0.5, **kwargs)
|
||||
if not r.ok:
|
||||
raise RuntimeError(
|
||||
f"[{self.name}] Error getting {address}; reply was {r.status_code} => {r.reason}"
|
||||
)
|
||||
return r.json()
|
||||
|
||||
def _go_n_put(self, address, **kwargs):
|
||||
"""Helper function to connect to smargopolo"""
|
||||
cmd = f"{self.sg_url.get()}/{address}"
|
||||
try:
|
||||
with mutex:
|
||||
r = self._s.put(cmd, timeout=1, **kwargs)
|
||||
except TimeoutError:
|
||||
try:
|
||||
time.sleep(0.05)
|
||||
with mutex:
|
||||
r = self._s.put(cmd, timeout=0.5, **kwargs)
|
||||
except TimeoutError:
|
||||
time.sleep(0.05)
|
||||
with mutex:
|
||||
r = self._s.put(cmd, timeout=0.5, **kwargs)
|
||||
if not r.ok:
|
||||
raise RuntimeError(
|
||||
f"[{self.name}] Error putting {address}; reply was {r.status_code} => {r.reason}"
|
||||
)
|
||||
return r.json()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
shx = SmarGonAxis(prefix="SCS", name="shx", sg_url="http://x06da-smargopolo.psi.ch:3000")
|
||||
shy = SmarGonAxis(prefix="SCS", name="shy", sg_url="http://x06da-smargopolo.psi.ch:3000")
|
||||
shz = SmarGonAxis(
|
||||
prefix="SCS",
|
||||
name="shz",
|
||||
low_limit=10,
|
||||
high_limit=22,
|
||||
sg_url="http://x06da-smargopolo.psi.ch:3000",
|
||||
)
|
||||
shx.wait_for_connection()
|
||||
shy.wait_for_connection()
|
||||
shz.wait_for_connection()
|
||||
@@ -0,0 +1,393 @@
|
||||
# #!/usr/bin/env python3
|
||||
|
||||
# from time import sleep, time
|
||||
# from typing import Tuple
|
||||
|
||||
# from requests import get, put
|
||||
|
||||
# from beamline import beamline
|
||||
# from mx_redis import SMARGON
|
||||
|
||||
# try:
|
||||
# from mx_preferences import get_config
|
||||
|
||||
# host = get_config(beamline)["smargon"]["host"]
|
||||
# port = get_config(beamline)["smargon"]["port"]
|
||||
# except Exception:
|
||||
# host = "x06da-smargopolo.psi.ch"
|
||||
# port = 3000
|
||||
# base = f"http://{host}:{port}"
|
||||
|
||||
|
||||
# def gonget(thing: str, **kwargs) -> dict:
|
||||
# """issue a GET for some API component on the smargopolo server"""
|
||||
# cmd = f"{base}/{thing}"
|
||||
# if kwargs.get("verbose", False):
|
||||
# print(cmd)
|
||||
# r = get(cmd)
|
||||
# if not r.ok:
|
||||
# raise Exception(f"error getting {thing}; server returned {r.status_code} => {r.reason}")
|
||||
# return r.json()
|
||||
|
||||
|
||||
# def gonput(thing: str, **kwargs):
|
||||
# """issue a PUT for some API component on the smargopolo server"""
|
||||
# cmd = f"{base}/{thing}"
|
||||
# if kwargs.get("verbose", False):
|
||||
# print(cmd)
|
||||
# put(cmd)
|
||||
|
||||
|
||||
# def scsput(**kwargs):
|
||||
# """
|
||||
# Issue a new absolute target in the SH coordinate system.
|
||||
|
||||
# The key "verbose" may be passed in kwargs with any true
|
||||
# value for verbose behaviour.
|
||||
|
||||
|
||||
# :param kwargs: a dict containing keys ("shx", "shy", "shz", "chi", "phi")
|
||||
# :type kwargs: dict
|
||||
# :return:
|
||||
# :rtype:
|
||||
# """
|
||||
# xyz = {
|
||||
# k.upper(): v for k, v in kwargs.items() if k.lower() in ("shx", "shy", "shz", "chi", "phi")
|
||||
# }
|
||||
# thing = "&".join([f"{k.upper()}={float(v):.5f}" for k, v in xyz.items()])
|
||||
# cmd = f"{base}/targetSCS?{thing}"
|
||||
# if kwargs.get("verbose", False):
|
||||
# print(cmd)
|
||||
# put(cmd)
|
||||
|
||||
|
||||
# def bcsput(**kwargs):
|
||||
# """
|
||||
# Issue a new absolute target in the beamline coordinate system.
|
||||
|
||||
# The key "verbose" may be passed in kwargs with any true
|
||||
# value for verbose behaviour.
|
||||
|
||||
|
||||
# :param kwargs: a dict containing keys ("bx", "by", "bz", "chi", "phi")
|
||||
# :return:
|
||||
# :rtype:
|
||||
# """
|
||||
# xyz = {k.upper(): v for k, v in kwargs.items() if k.lower() in ("bx", "by", "bz", "chi", "phi")}
|
||||
# thing = "&".join([f"{k.upper()}={float(v):.5f}" for k, v in xyz.items()])
|
||||
# cmd = f"{base}/targetBCS?{thing}"
|
||||
# if kwargs.get("verbose", False):
|
||||
# print(cmd)
|
||||
# put(cmd)
|
||||
|
||||
|
||||
# def scsrelput(**kwargs) -> None:
|
||||
# """
|
||||
# Issue relative increments to current SH coordinate system.
|
||||
|
||||
# The key "verbose" may be passed in kwargs with any true
|
||||
# value for verbose behaviour.
|
||||
|
||||
|
||||
# :param kwargs: a dict containing keys ("shx", "shy", "shz", "chi", "phi")
|
||||
# :type kwargs: dict
|
||||
# :return:
|
||||
# :rtype:
|
||||
# """
|
||||
# xyz = {
|
||||
# k.upper(): v for k, v in kwargs.items() if k.lower() in ("shx", "shy", "shz", "chi", "phi")
|
||||
# }
|
||||
# thing = "&".join([f"{k.upper()}={float(v):.5f}" for k, v in xyz.items()])
|
||||
# cmd = f"{base}/targetSCS_rel?{thing}"
|
||||
# if kwargs.get("verbose", False):
|
||||
# print(cmd)
|
||||
# put(cmd)
|
||||
|
||||
|
||||
# def bcsrelput(**kwargs):
|
||||
# """
|
||||
# Issue relative increments to current beamline coordinate system.
|
||||
|
||||
# The key "verbose" may be passed in kwargs with any true
|
||||
# value for verbose behaviour.
|
||||
|
||||
# :param kwargs: a dict containing keys ("bx", "by", "bz")
|
||||
# :type kwargs: dict
|
||||
# :return:
|
||||
# :rtype:
|
||||
# """
|
||||
# xyz = {k.upper(): v for k, v in kwargs.items() if k.lower() in ("bx", "by", "bz")}
|
||||
# thing = "&".join([f"{k.upper()}={float(v):.5f}" for k, v in xyz.items()])
|
||||
# cmd = f"{base}/targetBCS_rel?{thing}"
|
||||
# if kwargs.get("verbose", False):
|
||||
# print(cmd)
|
||||
# put(cmd)
|
||||
|
||||
|
||||
# # url_redis = f"{beamline}-cons-705.psi.ch"
|
||||
# # print(f"connecting to redis DB #3 on host: {url_redis}")
|
||||
# # redis_handle = redis.StrictRedis(host=url_redis, db=3)
|
||||
# # pubsub = redis_handle.pubsub()
|
||||
|
||||
# MODE_UNINITIALIZED = 0
|
||||
# MODE_INITIALIZING = 1
|
||||
# MODE_READY = 2
|
||||
# MODE_ERROR = 99
|
||||
|
||||
|
||||
# class SmarGon(object):
|
||||
# def __init__(self):
|
||||
# super(SmarGon, self).__init__()
|
||||
# self.__dict__.update(target=None)
|
||||
# self.__dict__.update(bookmarks={})
|
||||
# self.__dict__.update(_latest_message={})
|
||||
# # pubsub.psubscribe(**{f"__keyspace@{SMARGON.value}__:*": self._cb_readbackSCS})
|
||||
# # pubsub.run_in_thread(sleep_time=0.5, daemon=True)
|
||||
|
||||
# def __repr__(self):
|
||||
# BX, BY, BZ, OMEGA, CHI, PHI, a, b, c = self.readback_bcs().values()
|
||||
# return f"<{self.__class__.__name__} X={BX:.3f}, Y={BY:.3f}, Z={BZ:.3f}, CHI={CHI:.3f}, PHI={PHI:.3f}, OMEGA={OMEGA:.3f}>"
|
||||
|
||||
# def _cb_readbackSCS(self, msg):
|
||||
# if msg["data"] in ["hset"]:
|
||||
# self._latest_message = msg
|
||||
|
||||
# def move_home(self, wait=False) -> None:
|
||||
# """move to beamline coordinate system X, Y, Z, Chi, Phi = 0 0 0 0 0"""
|
||||
# self.apply_bookmark_sh({"shx": 0.0, "shy": 0.0, "shz": 18.0, "chi": 0.0, "phi": 0.0})
|
||||
# if wait:
|
||||
# self.wait_home()
|
||||
|
||||
# def xyz(self, coords: Tuple[float, float, float], wait: bool = True) -> None:
|
||||
# """
|
||||
# Move smargon in absolute beamline coordinates
|
||||
|
||||
# :param coords: a tuple of floats representing X, Y, Z coordinates
|
||||
# :type coords:
|
||||
# :param wait:
|
||||
# :type wait:
|
||||
# :return:
|
||||
# :rtype:
|
||||
# """
|
||||
# x, y, z = coords
|
||||
# # the two steps below are necessary otherwise the control system
|
||||
# # remembers *a* previous CHI
|
||||
# bcs = self.bcs
|
||||
# bcs.update({"BX": x, "BY": y, "BZ": z})
|
||||
# self.bcs = bcs
|
||||
# if wait:
|
||||
# self.wait()
|
||||
|
||||
# def wait_home(self, timeout: float = 20.0) -> None:
|
||||
# """
|
||||
# wait for the smargon to reach its home position:
|
||||
# SHX = 0.0
|
||||
# SHY = 0.0
|
||||
# SHZ = 18.0
|
||||
# CHI = 0.0
|
||||
# PHI = 0.0
|
||||
|
||||
# :param timeout: time to wait for positions to be reached raises TimeoutError if timeout reached
|
||||
# :type timeout: float
|
||||
# :return:
|
||||
# :rtype:
|
||||
# """
|
||||
# tout = timeout + time()
|
||||
# in_place = [False, False]
|
||||
# rbv = -999.0
|
||||
# while not all(in_place) and time() < tout:
|
||||
# rbv = self.readback_scs()
|
||||
# in_place = []
|
||||
# for k, v in {"SHX": 0.0, "SHY": 0.0, "SHZ": 18.0, "CHI": 0.0, "PHI": 0.0}.items():
|
||||
# in_place.append(abs(rbv[k] - v) < 0.01)
|
||||
# if time() > tout:
|
||||
# raise TimeoutError(f"timeout waiting for smargon to reach home position: {rbv}")
|
||||
|
||||
# def push_bookmark(self):
|
||||
# """
|
||||
# save current absolute coordinates in FIFO stack
|
||||
# :return:
|
||||
# :rtype:
|
||||
# """
|
||||
# t = round(time())
|
||||
# self.bookmarks[t] = self.readback_scs()
|
||||
|
||||
# def pop_bookmark(self):
|
||||
# return self.bookmarks.popitem()[1]
|
||||
|
||||
# def apply_bookmark_sh(self, scs):
|
||||
# scsput(**scs)
|
||||
|
||||
# def apply_last_bookmark_sh(self):
|
||||
# scs = self.pop_bookmark()
|
||||
# scsput(**scs)
|
||||
|
||||
# def readback_mcs(self):
|
||||
# """current motor positions of the smargon sliders"""
|
||||
# return gonget("readbackMCS")
|
||||
|
||||
# def readback_scs(self):
|
||||
# """current SH coordinates of the smargon model"""
|
||||
# return gonget("readbackSCS")
|
||||
|
||||
# def readback_bcs(self):
|
||||
# """current beamline coordinates of the smargon"""
|
||||
# return gonget("readbackBCS")
|
||||
|
||||
# def target_scs(self):
|
||||
# """currently assigned targets for the smargon control system"""
|
||||
# return gonget("targetSCS")
|
||||
|
||||
# def initialize(self):
|
||||
# """initialize the smargon"""
|
||||
# self.set_mode(MODE_UNINITIALIZED)
|
||||
# sleep(0.1)
|
||||
# self.set_mode(MODE_INITIALIZING)
|
||||
|
||||
# def set_mode(self, mode: int):
|
||||
# """put smargon control system in a given mode
|
||||
# MODE_UNINITIALIZED = 0
|
||||
# MODE_INITIALIZING = 1
|
||||
# MODE_READY = 2
|
||||
# MODE_ERROR = 99
|
||||
# """
|
||||
# gonput(f"mode?mode={mode}")
|
||||
|
||||
# def enable_correction(self):
|
||||
# """enable calibration based corrections"""
|
||||
# gonput("corr_type?corr_type=1")
|
||||
|
||||
# def disable_correction(self):
|
||||
# """disable calibration based corrections"""
|
||||
# gonput("corr_type?corr_type=0")
|
||||
|
||||
# def chi(self, val=None, wait=False):
|
||||
# if val is None:
|
||||
# return self.readback_scs()["CHI"]
|
||||
# scsput(CHI=val)
|
||||
# if wait:
|
||||
# timeout = 10 + time()
|
||||
# while time() < timeout:
|
||||
# if abs(val - self.readback_scs()["CHI"]) < 0.1:
|
||||
# break
|
||||
# if time() > timeout:
|
||||
# raise RuntimeError(f"SmarGon CHI did not reach requested target {val} in time")
|
||||
|
||||
# def phi(self, val=None, wait=False):
|
||||
# if val is None:
|
||||
# return self.readback_scs()["PHI"]
|
||||
# scsput(PHI=val)
|
||||
# if wait:
|
||||
# timeout = 70 + time()
|
||||
# while time() < timeout:
|
||||
# if abs(val - self.readback_scs()["PHI"]) < 0.1:
|
||||
# break
|
||||
# if time() > timeout:
|
||||
# raise RuntimeError(f"SmarGon PHI did not reach requested target {val} in time")
|
||||
|
||||
# def wait(self, timeout=60.0):
|
||||
# """waits up to `timeout` seconds for smargon to reach target"""
|
||||
# target = {
|
||||
# k.upper(): v
|
||||
# for k, v in self.target_scs().items()
|
||||
# if k.lower() in ("shx", "shy", "shz", "chi", "phi")
|
||||
# }
|
||||
|
||||
# timeout = timeout + time()
|
||||
# while time() < timeout:
|
||||
# s = {
|
||||
# k: (abs(v - target[k]) < 0.01)
|
||||
# for k, v in self.readback_scs().items()
|
||||
# if k.upper() in ("SHX", "SHY", "SHZ", "CHI", "PHI")
|
||||
# }
|
||||
# if all(list(s.values())):
|
||||
# break
|
||||
# if time() > timeout:
|
||||
# raise TimeoutError("timed out waiting for smargon to reach target")
|
||||
|
||||
# def __setattr__(self, key, value):
|
||||
# key = key.lower()
|
||||
# if key == "mode":
|
||||
# self.set_mode(value)
|
||||
# elif key == "correction":
|
||||
# assert value in (
|
||||
# 0,
|
||||
# 1,
|
||||
# False,
|
||||
# True,
|
||||
# ), "correction is either 1 or True (enabled) or 0 (disabled)"
|
||||
# gonput(f"corr_type?corr_type?{value}")
|
||||
# elif key == "scs":
|
||||
# scsput(**value)
|
||||
# elif key == "bcs":
|
||||
# bcsput(**value)
|
||||
# elif key == "target":
|
||||
# if not isinstance(value, dict):
|
||||
# raise Exception(
|
||||
# f"expected a dict with target axis and values got something else: {value}"
|
||||
# )
|
||||
# for k in value.keys():
|
||||
# if k.lower() not in "shx shy shz chi phi ox oy oz".split():
|
||||
# raise Exception(f'unknown axis in target "{k}"')
|
||||
# scsput(**value)
|
||||
# elif key in "shx shy shz chi phi ox oy oz".split():
|
||||
# scsput(**{key: value})
|
||||
# elif key in "bx by bz".split():
|
||||
# bcs = self.readback_bcs()
|
||||
# bcs[key] = value
|
||||
# bcsput(**bcs)
|
||||
# else:
|
||||
# self.__dict__[key].update(value)
|
||||
|
||||
# def __getattr__(self, key):
|
||||
# key = key.lower()
|
||||
# if key == "mode":
|
||||
# return self.readback_mcs()["mode"]
|
||||
# elif key == "correction":
|
||||
# return gonget("corr_type")
|
||||
# elif key == "bcs":
|
||||
# return self.readback_bcs()
|
||||
# elif key == "mcs":
|
||||
# return self.readback_mcs()
|
||||
# elif key == "scs":
|
||||
# return self.readback_scs()
|
||||
# elif key in "shx shy shz chi phi ox oy oz".split():
|
||||
# return self.readback_scs()[key.upper()]
|
||||
# elif key in "bx by bz".split():
|
||||
# return self.readback_bcs()[key.upper()]
|
||||
# else:
|
||||
# return self.__getattribute__(key)
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# import argparse
|
||||
|
||||
# parser = argparse.ArgumentParser(description="SmarGon client")
|
||||
# parser.add_argument("-i", "--initialize", help="initialize smargon", action="store_true")
|
||||
# args = parser.parse_args()
|
||||
|
||||
# smargon = SmarGon()
|
||||
|
||||
# if args.initialize:
|
||||
# print("initializing smargon device")
|
||||
# import Aerotech
|
||||
|
||||
# print("moving aerotech back by 50mm")
|
||||
# abr = Aerotech.Abr()
|
||||
|
||||
# abr.incr_x(-50.0, wait=True, velo=100.0)
|
||||
|
||||
# print("issuing init command to smargon")
|
||||
# smargon.initialize()
|
||||
# sleep(0.5)
|
||||
|
||||
# print("waiting for init routine to complete")
|
||||
# while MODE_READY != smargon.mode:
|
||||
# sleep(0.5)
|
||||
|
||||
# print("moving smargon to HOME position")
|
||||
# smargon.move_home()
|
||||
|
||||
# print("moving aerotech to its previous position")
|
||||
# abr.incr_x(50.0, wait=True, velo=100.0)
|
||||
# exit(0)
|
||||
@@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Standard DAQ preview image stream module
|
||||
|
||||
Created on Thu Jun 27 17:28:43 2024
|
||||
|
||||
@author: mohacsi_i
|
||||
"""
|
||||
import json
|
||||
from time import sleep, time
|
||||
from threading import Thread
|
||||
import zmq
|
||||
import numpy as np
|
||||
from ophyd import Device, Signal, Component, Kind
|
||||
from ophyd_devices.interfaces.base_classes.psi_detector_base import (
|
||||
CustomDetectorMixin,
|
||||
PSIDetectorBase,
|
||||
)
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
ZMQ_TOPIC_FILTER = b""
|
||||
|
||||
|
||||
class StdDaqPreviewMixin(CustomDetectorMixin):
|
||||
"""Setup class for the standard DAQ preview stream
|
||||
|
||||
Parent class: CustomDetectorMixin
|
||||
"""
|
||||
|
||||
_mon = None
|
||||
|
||||
def on_stage(self):
|
||||
"""Start listening for preview data stream"""
|
||||
if self._mon is not None:
|
||||
self.parent.unstage()
|
||||
sleep(0.5)
|
||||
|
||||
logger.info(f"[{self.parent.name}] Attaching monitor to {self.parent.url.get()}")
|
||||
self.parent.connect()
|
||||
self._stop_polling = False
|
||||
self._mon = Thread(target=self.poll, daemon=True)
|
||||
self._mon.start()
|
||||
|
||||
def on_unstage(self):
|
||||
"""Stop a running preview"""
|
||||
if self._mon is not None:
|
||||
self._stop_polling = True
|
||||
# Might hang on recv_multipart
|
||||
self._mon.join(timeout=1)
|
||||
# So also disconnect the socket
|
||||
try:
|
||||
# pylint: disable=protected-access
|
||||
self.parent._socket.disconnect(self.parent.url.get())
|
||||
except zmq.error.ZMQError:
|
||||
# Might be already closed
|
||||
pass
|
||||
|
||||
def on_stop(self):
|
||||
"""Stop a running preview"""
|
||||
self.on_unstage()
|
||||
|
||||
def poll(self):
|
||||
"""Collect streamed updates"""
|
||||
try:
|
||||
t_last = time()
|
||||
while True:
|
||||
try:
|
||||
# Exit loop and finish monitoring
|
||||
if self._stop_polling:
|
||||
logger.info(f"[{self.parent.name}]\tDetaching monitor")
|
||||
break
|
||||
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=protected-access
|
||||
r = self.parent._socket.recv_multipart(flags=zmq.NOBLOCK)
|
||||
|
||||
# Length and throtling checks
|
||||
if len(r) != 2:
|
||||
logger.warning(
|
||||
f"[{self.parent.name}] Received malformed array of length {len(r)}"
|
||||
)
|
||||
t_curr = time()
|
||||
t_elapsed = t_curr - t_last
|
||||
if t_elapsed < self.parent.throttle.get():
|
||||
sleep(0.1)
|
||||
continue
|
||||
|
||||
# Unpack the Array V1 reply to metadata and array data
|
||||
meta, data = r
|
||||
|
||||
# Update image and update subscribers
|
||||
header = json.loads(meta)
|
||||
image = np.frombuffer(data, dtype=header["type"])
|
||||
if image.size != np.prod(header["shape"]):
|
||||
err = f"Unexpected array size of {image.size} for header: {header}"
|
||||
raise ValueError(err)
|
||||
image = image.reshape(header["shape"])
|
||||
|
||||
# Update image and update subscribers
|
||||
self.parent.array_counter.put(header["frame"], force=True)
|
||||
self.parent.ndimensions.put(len(header["shape"]), force=True)
|
||||
self.parent.array_size.put(header["shape"], force=True)
|
||||
# self.parent.array_data.put(data, force=True)
|
||||
self.parent.shaped_image.put(image, force=True)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
self.parent._last_image = image
|
||||
self.parent._run_subs(sub_type=self.parent.SUB_MONITOR, value=image)
|
||||
t_last = t_curr
|
||||
logger.info(
|
||||
f"[{self.parent.name}] Updated frame {header['frame']}\t"
|
||||
f"Shape: {header['shape']}\tMean: {np.mean(image):.3f}"
|
||||
)
|
||||
except ValueError:
|
||||
# Happens when ZMQ partially delivers the multipart message
|
||||
pass
|
||||
except zmq.error.Again:
|
||||
# Happens when receive queue is empty
|
||||
sleep(0.1)
|
||||
except Exception as ex:
|
||||
logger.info(f"[{self.parent.name}]\t{str(ex)}")
|
||||
raise
|
||||
finally:
|
||||
self._mon = None
|
||||
logger.info(f"[{self.parent.name}]\tDetaching monitor")
|
||||
|
||||
|
||||
class StdDaqPreviewDetector(PSIDetectorBase):
|
||||
"""Detector wrapper class around the StdDaq preview image stream.
|
||||
|
||||
This was meant to provide live image stream directly from the StdDAQ but
|
||||
also works with other ARRAY v1 streamers, like the AreaDetector ZMQ plugin.
|
||||
Note that the preview stream must be already throtled in order to cope with
|
||||
the incoming data and the python class might throttle it further.
|
||||
|
||||
NOTE: As an explicit request, it does not record the image data.
|
||||
|
||||
You can add a preview widget to the dock by:
|
||||
cam_widget = gui.add_dock('cam_dock1').add_widget('BECFigure').image('daq_stream1')
|
||||
"""
|
||||
|
||||
# Subscriptions for plotting image
|
||||
USER_ACCESS = ["image", "savemode"]
|
||||
SUB_MONITOR = "device_monitor_2d"
|
||||
_default_sub = SUB_MONITOR
|
||||
|
||||
custom_prepare_cls = StdDaqPreviewMixin
|
||||
|
||||
# Configuration attributes
|
||||
url = Component(Signal, kind=Kind.config, metadata={"write_access": False})
|
||||
throttle = Component(Signal, value=0.25, kind=Kind.config)
|
||||
# Streamed data status
|
||||
array_counter = Component(Signal, kind=Kind.hinted, metadata={"write_access": False})
|
||||
ndimensions = Component(Signal, kind=Kind.normal, metadata={"write_access": False})
|
||||
array_size = Component(Signal, kind=Kind.normal, metadata={"write_access": False})
|
||||
# array_data = Component(Signal, kind=Kind.omitted, metadata={"write_access": False})
|
||||
shaped_image = Component(Signal, kind=Kind.omitted, metadata={"write_access": False})
|
||||
_last_image = None
|
||||
|
||||
def __init__(
|
||||
self, *args, url: str = "tcp://129.129.95.38:20000", parent: Device = None, **kwargs
|
||||
) -> None:
|
||||
super().__init__(*args, parent=parent, **kwargs)
|
||||
self.url.set(url, force=True).wait()
|
||||
# Connect to the DAQ
|
||||
self.connect()
|
||||
|
||||
def connect(self):
|
||||
"""Connect to te StDAQs PUB-SUB streaming interface
|
||||
|
||||
StdDAQ may reject connection for a few seconds when it restarts,
|
||||
so if it fails, wait a bit and try to connect again.
|
||||
"""
|
||||
# pylint: disable=no-member
|
||||
# Socket to talk to server
|
||||
context = zmq.Context()
|
||||
self._socket = context.socket(zmq.SUB)
|
||||
self._socket.setsockopt(zmq.SUBSCRIBE, ZMQ_TOPIC_FILTER)
|
||||
try:
|
||||
self._socket.connect(self.url.get())
|
||||
except ConnectionRefusedError:
|
||||
sleep(1)
|
||||
self._socket.connect(self.url.get())
|
||||
|
||||
def savemode(self, save=False):
|
||||
"""Toggle save mode for the shaped image"""
|
||||
# pylint: disable=protected-access
|
||||
if save:
|
||||
self.shaped_image._kind = Kind.normal
|
||||
else:
|
||||
self.shaped_image._kind = Kind.omitted
|
||||
|
||||
def image(self):
|
||||
"""
|
||||
Gets the last image as an attribute in case image must be abandoned
|
||||
due to some caching on the BEC.
|
||||
"""
|
||||
return self._last_image
|
||||
|
||||
|
||||
# Automatically connect to MicroSAXS testbench if directly invoked
|
||||
if __name__ == "__main__":
|
||||
daq = StdDaqPreviewDetector(url="tcp://129.129.95.111:20000", name="preview")
|
||||
daq.wait_for_connection()
|
||||
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Ophyd devices for the PX III beamline, including the MX specific Aerotech A3200 stage.
|
||||
|
||||
@author: mohacsi_i
|
||||
"""
|
||||
from .A3200 import AerotechAbrStage
|
||||
from .A3200utils import A3200Axis
|
||||
from .SmarGonA import SmarGonAxis as SmarGonAxisA
|
||||
from .SmarGonB import SmarGonAxis as SmarGonAxisB
|
||||
from .StdDaqPreview import StdDaqPreviewDetector
|
||||
from .NDArrayPreview import NDArrayPreview
|
||||
from .SamCamDetector import SamCamDetector
|
||||
from .PneumaticValve import PneumaticValve
|
||||
@@ -0,0 +1,43 @@
|
||||
from ophyd import Component as Cpt
|
||||
|
||||
from .http import TIMESTAMP_ID, HttpDeviceController, HttpDeviceSignal, HttpOphydDevice
|
||||
|
||||
|
||||
class AerotechController(HttpDeviceController):
|
||||
_readback_endpoint = "/status"
|
||||
_target_endpoint = "/position"
|
||||
|
||||
def __init__(self, *, prefix, **kwargs):
|
||||
self._readbacks: dict[str, dict[str, float | bool]] = {}
|
||||
super().__init__(prefix=prefix, **kwargs)
|
||||
|
||||
def put(self, axis: str, val: float):
|
||||
self._rest_post(body={axis: val})
|
||||
|
||||
def get_readback(self, axis_id: str) -> tuple[float, float] | None:
|
||||
with self._readback_lock:
|
||||
if axis_id not in self._readbacks or TIMESTAMP_ID not in self._readbacks:
|
||||
return None
|
||||
return self._readbacks.get(axis_id)["pos"], self._readbacks.get(TIMESTAMP_ID) # type: ignore
|
||||
|
||||
|
||||
class Aerotech(HttpOphydDevice):
|
||||
controller_class = AerotechController
|
||||
|
||||
x = Cpt(HttpDeviceSignal, axis_identifier="x", tolerance=0.01)
|
||||
y = Cpt(HttpDeviceSignal, axis_identifier="y", tolerance=0.01)
|
||||
z = Cpt(HttpDeviceSignal, axis_identifier="z", tolerance=0.01)
|
||||
u = Cpt(HttpDeviceSignal, axis_identifier="u", tolerance=0.01)
|
||||
vel_u_deg_s = Cpt(HttpDeviceSignal, axis_identifier="vel_u_deg_s", tolerance=0.01)
|
||||
|
||||
|
||||
def _test():
|
||||
a = Aerotech(name="aerotech", prefix="http://mx-x06da-queue-01:5234")
|
||||
a.wait_for_connection()
|
||||
return a
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
aerotech = _test()
|
||||
print(aerotech.read())
|
||||
aerotech.stop()
|
||||
@@ -0,0 +1,11 @@
|
||||
// This file was autogenerated. Do not edit it manually.
|
||||
## Device List
|
||||
### pxiii_bec
|
||||
| Device | Documentation | Module |
|
||||
| :----- | :------------- | :------ |
|
||||
| A3200Axis | Positioner wrapper for A3200 axes<br><br><br> Positioner wrapper for motors on the Aerotech A3200 controller. As the IOC<br> does not provide a motor record, this class simply wraps axes into a<br> standard Ophyd positioner for the BEC. It also has some additional<br> functionality for error checking and diagnostics.<br><br> Examples<br> --------<br> omega = A3200Axis('X06DA-ES-DF1:OMEGA', base_pv='X06DA-ES')<br><br> Parameters<br> ----------<br> prefix : str<br> Axis PV name root.<br> base_pv : str (situational)<br> IOC PV name root, i.e. X06DA-ES if standalone class.<br> | [pxiii_bec.devices.A3200utils](https://gitlab.psi.ch/bec/pxiii_bec/-/blob/main/pxiii_bec/devices/A3200utils.py) |
|
||||
| AerotechAbrStage | Standard PX stage on A3200 controller<br><br> This is the wrapper class for the standard rotation stage layout for the PX<br> beamlines at SLS. It wraps the main rotation axis OMEGA (Aerotech ABR)and<br> the associated motion axes GMX, GMY and GMZ. The ophyd class associates to<br> the general PX measurement procedure, which is that the actual scan script<br> is running as an AeroBasic program on the controller and we communicate to<br> it via 10+1 global variables.<br> | [pxiii_bec.devices.A3200](https://gitlab.psi.ch/bec/pxiii_bec/-/blob/main/pxiii_bec/devices/A3200.py) |
|
||||
| NDArrayPreview | Wrapper class around AreaDetector's NDStdArray plugins<br><br> This is a standalone class to display images from AreaDetector's<br> ImagePlugin without using a parent device. It also offers BEC exposed<br> methods to transfer image and change image array Kind-ness.<br><br> NOTE: As an explicit request, it can toggle data recording<br> | [pxiii_bec.devices.NDArrayPreview](https://gitlab.psi.ch/bec/pxiii_bec/-/blob/main/pxiii_bec/devices/NDArrayPreview.py) |
|
||||
| SamCamDetector | Sample camera device<br><br> The SAMCAM continously streams images to the GUI and sample alignment<br> scripts via ZMQ.<br> | [pxiii_bec.devices.SamCamDetector](https://gitlab.psi.ch/bec/pxiii_bec/-/blob/main/pxiii_bec/devices/SamCamDetector.py) |
|
||||
| SmarGonAxis | SmarGon client deice<br><br> This class controls the SmarGon goniometer via the REST interface. All<br> SmarGon axes share a common mutex to manage actual HW access.<br> | [pxiii_bec.devices.SmarGonB](https://gitlab.psi.ch/bec/pxiii_bec/-/blob/main/pxiii_bec/devices/SmarGonB.py) |
|
||||
| StdDaqPreviewDetector | Detector wrapper class around the StdDaq preview image stream.<br><br> This was meant to provide live image stream directly from the StdDAQ but<br> also works with other ARRAY v1 streamers, like the AreaDetector ZMQ plugin.<br> Note that the preview stream must be already throtled in order to cope with<br> the incoming data and the python class might throttle it further.<br><br> NOTE: As an explicit request, it does not record the image data.<br><br> You can add a preview widget to the dock by:<br> cam_widget = gui.add_dock('cam_dock1').add_widget('BECFigure').image('daq_stream1')<br> | [pxiii_bec.devices.StdDaqPreview](https://gitlab.psi.ch/bec/pxiii_bec/-/blob/main/pxiii_bec/devices/StdDaqPreview.py) |
|
||||
@@ -0,0 +1,178 @@
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from threading import Event, RLock, Thread
|
||||
from typing import Any
|
||||
|
||||
from ophyd import OphydObject
|
||||
from ophyd_devices import PSIDeviceBase
|
||||
from ophyd_devices.utils.socket import SocketSignal
|
||||
from requests import Response, Session
|
||||
|
||||
TIMESTAMP_ID = "__timestamp"
|
||||
_POLL_INTERVAL_SLOW = 0.1
|
||||
|
||||
|
||||
class HttpRestError(Exception):
|
||||
"""Error for rest calls from a HttpRestSignal."""
|
||||
|
||||
def __init__(self, resp: Response, *args: object, value: Any | None = None) -> None:
|
||||
method, url = resp.request.method, resp.request.url
|
||||
data = f"{str(value)} to " if value is not None else ""
|
||||
super().__init__(
|
||||
f"Could not {method} {data}{url}. Code: {resp.status_code}. Reason: {resp.reason}.",
|
||||
*args,
|
||||
)
|
||||
|
||||
|
||||
class HttpDeviceController(OphydObject, ABC):
|
||||
"""Controller to consolidate polling loops and other REST calls for devices which communicate
|
||||
with HTTP REST interfaces"""
|
||||
|
||||
_readback_endpoint: str
|
||||
_target_endpoint: str
|
||||
|
||||
def __init__(self, *, prefix, **kwargs):
|
||||
self._readbacks: dict
|
||||
self._session = Session()
|
||||
self._prefix = prefix
|
||||
self._targets = {}
|
||||
self._signal_registry: set[str] = set()
|
||||
self._readback_poll_interval: float = _POLL_INTERVAL_SLOW
|
||||
|
||||
super().__init__(**kwargs)
|
||||
self._setup_readback()
|
||||
|
||||
def _setup_readback(self):
|
||||
self._stop_monitor_readback_event = Event()
|
||||
self._readback_lock = RLock()
|
||||
self._monitor_readback_thread = Thread(
|
||||
target=self._monitor,
|
||||
args=[
|
||||
self._readback_endpoint,
|
||||
self._stop_monitor_readback_event,
|
||||
self._readback_lock,
|
||||
self._readbacks,
|
||||
],
|
||||
)
|
||||
|
||||
def manual_update(self):
|
||||
self._update_reading(self._readback_endpoint, self._readback_lock, self._readbacks)
|
||||
|
||||
def _update_reading(self, endpoint: str, lock: RLock, buffer: dict):
|
||||
data = self._rest_get(endpoint)
|
||||
timestamp = time.monotonic()
|
||||
with lock:
|
||||
buffer.update(data)
|
||||
buffer["__timestamp"] = timestamp
|
||||
|
||||
def _monitor(self, endpoint: str, event: Event, lock: RLock, buffer: dict):
|
||||
while not event.is_set():
|
||||
self._update_reading(endpoint, lock, buffer)
|
||||
time.sleep(self._readback_poll_interval)
|
||||
|
||||
def _clean_monitor(self):
|
||||
if self._monitor_readback_thread.is_alive():
|
||||
self._stop_monitor_readback_event.set()
|
||||
self._monitor_readback_thread.join(timeout=2)
|
||||
if self._monitor_readback_thread.is_alive():
|
||||
raise RuntimeError("Failed to clean up Aerotech monitor thread.")
|
||||
|
||||
def register(self, axis_id: str):
|
||||
self._signal_registry.add(axis_id)
|
||||
|
||||
def _rest_get(self, endpoint):
|
||||
resp = self._session.get(self._prefix + endpoint)
|
||||
if not resp.ok:
|
||||
raise HttpRestError(resp)
|
||||
return resp.json()
|
||||
|
||||
def _rest_put(self, params: dict | None = None, body: dict | None = None):
|
||||
resp = self._session.put(self._prefix + self._target_endpoint, params=params, json=body)
|
||||
if not resp.ok:
|
||||
raise HttpRestError(resp, value=params)
|
||||
|
||||
def _rest_post(self, params: dict | None = None, body: dict | None = None):
|
||||
resp = self._session.post(self._prefix + self._target_endpoint, params=params, json=body)
|
||||
if not resp.ok:
|
||||
raise HttpRestError(resp, value=params)
|
||||
|
||||
def start_monitor(self):
|
||||
"""Start or restart the automonitor thread."""
|
||||
self._clean_monitor()
|
||||
self._setup_readback()
|
||||
self._monitor_readback_thread.start()
|
||||
|
||||
def monitor_stopped(self):
|
||||
return not self._monitor_readback_thread.is_alive()
|
||||
|
||||
def put(self, axis: str, val: float):
|
||||
self._rest_put({axis: val})
|
||||
|
||||
@abstractmethod
|
||||
def get_readback(self, axis_id: str) -> tuple[float, float] | None:
|
||||
"""Return a tuple (reading, timestamp) if the axis_id exists"""
|
||||
|
||||
def stop(self):
|
||||
# There doesn't appear to be a stop endpoint on the server
|
||||
# Best effort: set the target to the current position
|
||||
pass
|
||||
# TODO: self._rest_put(self._readbacks)
|
||||
|
||||
|
||||
class HttpDeviceSignal(SocketSignal):
|
||||
"""Ophyd signal which gets and puts to a REST API rather than EPICS PVs, mediated through the Aerotech
|
||||
Controller"""
|
||||
|
||||
def __init__(self, *args, axis_identifier: str, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
controller: HttpDeviceController | None = getattr(self.root, "controller", None)
|
||||
if controller is None:
|
||||
raise TypeError("HttpDeviceSignal must be used in a device with a HttpDeviceController")
|
||||
self._controller = controller
|
||||
self._axis_id = axis_identifier
|
||||
self._controller.register(self._axis_id)
|
||||
|
||||
def _socket_get(self): # type: ignore
|
||||
self._readback, self.metadata["timestamp"] = self._controller.get_readback(
|
||||
self._axis_id
|
||||
) or (0.0, 0.0)
|
||||
return self._readback
|
||||
|
||||
def _socket_set(self, val: float):
|
||||
self._controller.put(self._axis_id, val)
|
||||
|
||||
def get(self, **kwargs):
|
||||
if self._controller.monitor_stopped():
|
||||
self._controller.start_monitor()
|
||||
return super().get(**kwargs)
|
||||
|
||||
|
||||
class HttpOphydDevice(PSIDeviceBase):
|
||||
controller_class: type[HttpDeviceController]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
prefix: str = "",
|
||||
scan_info=None,
|
||||
device_manager=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.controller = self.controller_class(prefix=prefix)
|
||||
super().__init__(
|
||||
name=name,
|
||||
prefix=prefix,
|
||||
scan_info=scan_info,
|
||||
device_manager=device_manager,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def wait_for_connection(self, **kwargs): # type: ignore
|
||||
self.controller.start_monitor()
|
||||
self.controller.manual_update()
|
||||
return super().wait_for_connection(**kwargs)
|
||||
|
||||
def stop(self, *, success: bool = False) -> None:
|
||||
self.controller.stop()
|
||||
return super().stop(success=success)
|
||||
@@ -0,0 +1,42 @@
|
||||
from ophyd import Component as Cpt
|
||||
from ophyd_devices import PSIDeviceBase
|
||||
|
||||
from .http import HttpDeviceController, HttpDeviceSignal, HttpOphydDevice
|
||||
|
||||
_TIMESTAMP_ID = "__timestamp"
|
||||
_POLL_INTERVAL_SLOW = 0.1
|
||||
|
||||
|
||||
class SmargonController(HttpDeviceController):
|
||||
"""Controller to consolidate polling loops and other REST calls for the smargon"""
|
||||
|
||||
_readback_endpoint = "/readbackSCS"
|
||||
_target_endpoint = "/targetSCS"
|
||||
|
||||
def __init__(self, *, prefix, **kwargs):
|
||||
self._readbacks: dict[str, float] = {}
|
||||
super().__init__(prefix=prefix, **kwargs)
|
||||
|
||||
def get_readback(self, axis_id: str) -> tuple[float, float] | None:
|
||||
with self._readback_lock:
|
||||
if axis_id not in self._readbacks or _TIMESTAMP_ID not in self._readbacks:
|
||||
return None
|
||||
return self._readbacks.get(axis_id), self._readbacks.get(_TIMESTAMP_ID) # type: ignore
|
||||
|
||||
def put(self, axis: str, val: float):
|
||||
self._rest_put(params={axis: val})
|
||||
|
||||
def stop(self):
|
||||
# There doesn't appear to be a stop endpoint on the server
|
||||
# Best effort: set the target to the current position
|
||||
self._rest_put(params=self._readbacks)
|
||||
|
||||
|
||||
class Smargon(HttpOphydDevice):
|
||||
controller_class = SmargonController
|
||||
|
||||
x = Cpt(HttpDeviceSignal, axis_identifier="SHX", tolerance=0.01)
|
||||
y = Cpt(HttpDeviceSignal, axis_identifier="SHY", tolerance=0.01)
|
||||
z = Cpt(HttpDeviceSignal, axis_identifier="SHZ", tolerance=0.01)
|
||||
phi = Cpt(HttpDeviceSignal, axis_identifier="PHI", tolerance=0.01)
|
||||
chi = Cpt(HttpDeviceSignal, axis_identifier="CHI", tolerance=0.01)
|
||||
@@ -0,0 +1,6 @@
|
||||
# Macros
|
||||
|
||||
This directory is intended to store macros which will be loaded automatically when starting BEC.
|
||||
Macros are small functions to make repetitive tasks easier. Functions defined in python files in this directory will be accessible from the BEC console.
|
||||
Please do not put any code outside of function definitions here. If you wish for code to be automatically run when starting BEC, see the startup script at pxiii_bec/bec_ipython_client/startup/post_startup.py
|
||||
For a guide on writing macros, please see: https://bec.readthedocs.io/en/latest/user/command_line_interface.html#how-to-write-a-macro
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Planner to move between beamline statesΩ"""
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
# from enums import BeamlineState, TemperatureMode
|
||||
# from matcher import DeviceMatcher, nonzero_is_on
|
||||
|
||||
|
||||
class StateChangePlanner:
|
||||
"""Moves devices to the correct positions to achieve a given state"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
devices,
|
||||
states: dict[BeamlineState, dict[str, str]],
|
||||
allow_modifiers=None,
|
||||
deps=None,
|
||||
stage_timeout=5,
|
||||
debug=False,
|
||||
):
|
||||
self.devices = devices
|
||||
self.states = states
|
||||
self.allow_modifiers = allow_modifiers or {}
|
||||
self.deps = deps
|
||||
self.stage_timeout = stage_timeout
|
||||
self.debug = debug
|
||||
|
||||
self.modifiers = {
|
||||
TemperatureMode.CRYO: {"cryo_pos": "in"},
|
||||
TemperatureMode.ROOM_TEMP: {"cryo_pos": "out"},
|
||||
}
|
||||
|
||||
self.matcher = DeviceMatcher()
|
||||
# Register rules
|
||||
self.matcher.register("bl_bright", nonzero_is_on)
|
||||
self.matcher.register("fl_bright", nonzero_is_on)
|
||||
# self.matcher.register("bs_z", threshold_rule("safe"))
|
||||
|
||||
def _merged_state(self, state, modifier):
|
||||
target = dict(self.states[state])
|
||||
|
||||
if modifier:
|
||||
if isinstance(modifier, str):
|
||||
modifier = TemperatureMode(modifier)
|
||||
|
||||
if self.allow_modifiers.get(state, False):
|
||||
target.update(self.modifiers[modifier])
|
||||
return target
|
||||
|
||||
def execute_plan(self, stage, state_name):
|
||||
"""Execute the planning stage"""
|
||||
statuses = []
|
||||
moved = []
|
||||
start = time.time()
|
||||
|
||||
# trigger moves in parallel
|
||||
for dev, pos in stage:
|
||||
d = self.devices[dev]
|
||||
if not d.is_at(pos):
|
||||
# status = d.mv(pos)
|
||||
status = d.mv(pos)
|
||||
statuses.append((dev, pos, status))
|
||||
moved.append((dev, d, pos))
|
||||
|
||||
# wait for all to finish
|
||||
for dev, pos, status in statuses:
|
||||
remaining = self.stage_timeout - (time.time() - start)
|
||||
if remaining <= 0:
|
||||
raise RuntimeError(f"Stage timeout while moving to {state_name.name}")
|
||||
try:
|
||||
status.wait(timeout=remaining)
|
||||
except Exception:
|
||||
print(f"\nTimeout waiting for {dev} -> {pos}")
|
||||
# print("Positions:", self.print_positions())
|
||||
raise
|
||||
|
||||
# optional final verification (recommended for beamlines)
|
||||
for dev, d, pos in moved:
|
||||
if not self.matcher.matches(dev, d, pos):
|
||||
raise RuntimeError(
|
||||
f"{dev} did not reach position '{pos}' while moving to "
|
||||
f"{state_name.name}. Check motor status in EPICS."
|
||||
)
|
||||
print("Stage complete.")
|
||||
|
||||
def move_to(self, state_name, modifier=None):
|
||||
"""Move devices to the correct positions to achieve a given state"""
|
||||
if isinstance(state_name, str):
|
||||
state_name = BeamlineState(state_name)
|
||||
target = self._merged_state(state_name, modifier)
|
||||
|
||||
plan = self._plan(target)
|
||||
print(len(plan), "stages to reach target state")
|
||||
|
||||
# print("PLAN:")
|
||||
# for i, stage in enumerate(plan):
|
||||
# print(f"Stage {i + 1}: {stage}")
|
||||
seq = 1
|
||||
for stage in plan:
|
||||
print(f"Stage {seq}: {stage}")
|
||||
self.execute_plan(stage, state_name)
|
||||
seq += 1
|
||||
|
||||
def available_states(self):
|
||||
"""Return a list of available states"""
|
||||
return list(self.states.keys())
|
||||
|
||||
def get_positions(self):
|
||||
"""Return current positions of all SE devices"""
|
||||
return {name: dev.pos for name, dev in self.devices.items()}
|
||||
|
||||
def print_positions(self):
|
||||
"""Return current state of all devices"""
|
||||
for name, device in self.devices.items():
|
||||
print(f"{name:10s} : {device.pos:10s} value: {device.actual}")
|
||||
|
||||
def diff_states(self, before):
|
||||
"""Return a dict of {device: (before, after)} pairs for devices that changed state"""
|
||||
after = self.get_positions()
|
||||
return {k: (before[k], after[k]) for k in before if before[k] != after[k]}
|
||||
|
||||
def current_state(self):
|
||||
"""Return all current matching BeamlineState and TemperatureMode combinations,
|
||||
prioritizing non-None modifiers first."""
|
||||
matches = [] # Store all matching (state, modifier) pairs
|
||||
|
||||
for state in self.states:
|
||||
# Start with prioritized modifiers: Non-None first, then None.
|
||||
modifiers = list(self.modifiers.keys())
|
||||
modifiers.append(None) # Add `None` as a fallback after real modifiers.
|
||||
|
||||
for modifier in modifiers:
|
||||
# Combine state and modifier to get full configuration
|
||||
config = self._merged_state(state, modifier)
|
||||
|
||||
# Check if all devices match their expected positions
|
||||
all_match = True
|
||||
|
||||
for d, expected in config.items():
|
||||
dev = self.devices[d]
|
||||
|
||||
if not self.matcher.matches(d, dev, expected):
|
||||
all_match = False
|
||||
break
|
||||
|
||||
if all_match:
|
||||
matches.append((state.name, modifier.name if modifier else None))
|
||||
|
||||
return matches if matches else None
|
||||
|
||||
def is_state(self, state, modifier=None):
|
||||
"""Check if the current state matches the given state and modifier."""
|
||||
actual = self.current_state()
|
||||
if not actual:
|
||||
return False
|
||||
|
||||
if modifier is None:
|
||||
# match any modifier
|
||||
return any(s == state.name for s, _ in actual)
|
||||
|
||||
return (state.name, modifier.name) in actual
|
||||
|
||||
def _plan(self, target):
|
||||
|
||||
graph = defaultdict(set)
|
||||
indeg = defaultdict(int)
|
||||
nodes = set()
|
||||
|
||||
for dev, pos in target.items():
|
||||
node = (dev, pos)
|
||||
nodes.add(node)
|
||||
|
||||
for dep in self.deps.get(node, []):
|
||||
graph[dep].add(node)
|
||||
indeg[node] += 1
|
||||
nodes.add(dep)
|
||||
|
||||
q = deque(n for n in nodes if indeg[n] == 0)
|
||||
stages = []
|
||||
|
||||
while q:
|
||||
stage = list(q)
|
||||
stages.append(stage)
|
||||
q.clear()
|
||||
|
||||
for n in stage:
|
||||
for m in graph[n]:
|
||||
indeg[m] -= 1
|
||||
if indeg[m] == 0:
|
||||
q.append(m)
|
||||
|
||||
if sum(len(s) for s in stages) != len(nodes):
|
||||
raise RuntimeError("Circular dependency in state dependencies")
|
||||
return stages
|
||||
@@ -0,0 +1,21 @@
|
||||
# from enums import BeamlineState
|
||||
import yaml
|
||||
|
||||
|
||||
class DefineStatesManager:
|
||||
@staticmethod
|
||||
def initialize_states(states_file):
|
||||
"""
|
||||
Returns the states and modifiers defined in the specified states file.
|
||||
"""
|
||||
with open(states_file, "r", encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
states = {}
|
||||
allow_modifiers = {}
|
||||
|
||||
for name, config in cfg["states"].items():
|
||||
state = BeamlineState(name)
|
||||
allow_modifiers[state] = config.pop("allow_modifiers", False)
|
||||
states[state] = config
|
||||
|
||||
return states, allow_modifiers
|
||||
@@ -0,0 +1,70 @@
|
||||
""" Build the sample environment devices"""
|
||||
import yaml
|
||||
|
||||
# from position_device import PositionDevice
|
||||
|
||||
def motor_resolver(bec_name):
|
||||
|
||||
candidates = [
|
||||
bec_name,
|
||||
bec_name.replace("_", "."),
|
||||
]
|
||||
|
||||
for path in candidates:
|
||||
try:
|
||||
obj = dev
|
||||
|
||||
for part in path.split("."):
|
||||
obj = getattr(obj, part)
|
||||
|
||||
return obj
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise ValueError(f"Cannot resolve motor for '{bec_name}'")
|
||||
|
||||
def build_devices(yaml_file, mock_devices):
|
||||
""" Build devices from the beamline states yaml"""
|
||||
|
||||
|
||||
state_devices = {}
|
||||
|
||||
with open(yaml_file, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
for bec_name, cfg in data.items():
|
||||
|
||||
user = cfg.get("userParameter")
|
||||
|
||||
# Skip devices without user parameters
|
||||
if not user:
|
||||
continue
|
||||
|
||||
|
||||
tol = user.get("tol", 0.1)
|
||||
|
||||
positions = {
|
||||
k: v for k, v in user.items()
|
||||
if k not in ("type", "tol")
|
||||
}
|
||||
|
||||
allow_arbitrary = (user["type"] == "continuous")
|
||||
|
||||
|
||||
|
||||
pos_dev = PositionDevice(
|
||||
bec_name=bec_name,
|
||||
mot_device = motor_resolver(bec_name),
|
||||
positions=positions,
|
||||
tol=tol,
|
||||
allow_arbitrary=allow_arbitrary,
|
||||
use_mock=bec_name in mock_devices,
|
||||
)
|
||||
|
||||
state_devices[bec_name] = pos_dev
|
||||
return state_devices
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Utility functions for calculating energy, wavelength, and Bragg angle."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
# from pxii_parameters import (EnergyDefaults, CamConversion)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Constants:
|
||||
"""Constants used in energy calculations"""
|
||||
|
||||
# # Physical Constants from https://physics.nist.gov/cuu/Constants/index.html
|
||||
ANGSTROM_CONVERSION = 1e10 # Convert meters to angstrom
|
||||
PLANCK_CONST_EV = 4.135667696e-15 # eV/Hz
|
||||
SPEED_OF_LIGHT = 299792458 # m/s
|
||||
|
||||
# d-spacings
|
||||
d_spacing = {120: 3.13481, 298: 3.13562}
|
||||
|
||||
|
||||
def speed_of_light_ang():
|
||||
"""
|
||||
Calculate the speed of light in angstroms per second.
|
||||
|
||||
Returns:
|
||||
float: The speed of light converted to angstroms per second.
|
||||
"""
|
||||
return Constants.SPEED_OF_LIGHT * Constants.ANGSTROM_CONVERSION
|
||||
|
||||
|
||||
def en_wav_factor():
|
||||
"""
|
||||
Calculate the energy wavelength factor.
|
||||
|
||||
This function computes a constant factor used to calculate energy
|
||||
values in relation to wavelength by combining Planck's constant,
|
||||
in eV/Hz, and the speed of light in angstrom.
|
||||
|
||||
Returns:
|
||||
float: The computed energy wavelength factor.
|
||||
"""
|
||||
return Constants.PLANCK_CONST_EV * speed_of_light_ang()
|
||||
|
||||
|
||||
# Helper Functions
|
||||
def convert_to_degrees(angle_mrad: float) -> float:
|
||||
"""
|
||||
Convert an angle from milliradians to degrees.
|
||||
|
||||
Args:
|
||||
angle_mrad: The angle value in milliradians.
|
||||
|
||||
Returns:
|
||||
The angle converted into degrees as a float.
|
||||
"""
|
||||
return np.rad2deg(angle_mrad / 1000)
|
||||
|
||||
|
||||
def create_conversion_result(
|
||||
energy_ev: float, wavelength: float, bragg_angle_mrad: float
|
||||
) -> dict:
|
||||
"""
|
||||
Creates a dictionary containing converted values of energy and angles.
|
||||
|
||||
This function takes the energy in electron-volts, the wavelength,
|
||||
and the Bragg angle in milliradians as input. It computes and
|
||||
returns a dictionary containing the energy in both electron-volts
|
||||
and kiloelectron-volts, the wavelength, the Bragg angle in milliradians,
|
||||
and the Bragg angle converted to degrees.
|
||||
|
||||
Args:
|
||||
energy_ev: Energy value in electron-volts.
|
||||
wavelength: Wavelength value.
|
||||
bragg_angle_mrad: Bragg angle in milliradians.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the following keys:
|
||||
- "energy_kev": Energy value in kiloelectron-volts.
|
||||
- "energy_ev": Energy value in electron-volts.
|
||||
- "wavelength": Wavelength value.
|
||||
- "bragg_angle_mrad": Bragg angle in milliradians.
|
||||
- "bragg_angle_deg": Bragg angle in degrees.
|
||||
"""
|
||||
return {
|
||||
"energy_kev": energy_ev / 1000,
|
||||
"energy_ev": energy_ev,
|
||||
"wavelength": wavelength,
|
||||
"bragg_angle_mrad": float(bragg_angle_mrad),
|
||||
"bragg_angle_deg": float(convert_to_degrees(bragg_angle_mrad)),
|
||||
}
|
||||
|
||||
|
||||
def print_conversion_result(result: dict) -> None:
|
||||
"""
|
||||
Prints the energy-related conversion results to the console.
|
||||
"""
|
||||
|
||||
line = (
|
||||
f"energy: {result['energy_ev']:.6g} eV, energy: {result['energy_kev']:.6g} keV, "
|
||||
f"wavelength: {result['wavelength']:.4g} Å, "
|
||||
f"bragg angle: {result['bragg_angle_mrad']:.5g} mrad, {result['bragg_angle_deg']:.4g} deg"
|
||||
)
|
||||
print(line)
|
||||
|
||||
|
||||
# Conversion Functions
|
||||
def calculate_wavelength_from_angle(bragg_angle_mrad: float, temp=120) -> float:
|
||||
"""
|
||||
calculate_wavelength_from_angle(bragg_angle_mrad: float) -> float
|
||||
|
||||
Arguments:
|
||||
bragg_angle_mrad: The Bragg angle in milliradians, used to compute the
|
||||
sine value required for the wavelength calculation.
|
||||
|
||||
Returns:
|
||||
The calculated wavelength as a float value.
|
||||
"""
|
||||
d = Constants.d_spacing[temp]
|
||||
return 2 * d * np.sin(bragg_angle_mrad / 1000)
|
||||
|
||||
|
||||
def calculate_energy_from_wavelength(wavelength: float) -> float:
|
||||
"""
|
||||
Calculates the energy of a photon based on its wavelength.
|
||||
|
||||
Args:
|
||||
wavelength: The wavelength of the photon in angstrom.
|
||||
|
||||
Returns:
|
||||
The energy of the photon in eV.
|
||||
"""
|
||||
return en_wav_factor() / wavelength
|
||||
|
||||
|
||||
def calculate_wavelength_from_energy(energy_ev: float) -> float:
|
||||
"""
|
||||
Calculates the wavelength of a photon from its energy.
|
||||
|
||||
Arguments:
|
||||
energy_ev: float
|
||||
The energy of the photon in electronvolts (eV).
|
||||
|
||||
Returns:
|
||||
float
|
||||
The calculated wavelength of the photon in angstrom.
|
||||
"""
|
||||
return en_wav_factor() / energy_ev
|
||||
|
||||
|
||||
def calculate_bragg_angle_from_wavelength(wavelength: float, temp=120) -> float:
|
||||
"""
|
||||
Calculate the Bragg angle in milliradians for a given wavelength.
|
||||
|
||||
Args:
|
||||
wavelength: The wavelength in angstrom.
|
||||
|
||||
Returns:
|
||||
The Bragg angle in milliradians as a float.
|
||||
"""
|
||||
d = Constants.d_spacing[temp]
|
||||
angle_rad = np.arcsin(wavelength / (2 * d))
|
||||
return angle_rad * 1000
|
||||
|
||||
|
||||
def convert_input_angle_to_mrad(bragg_angle: float) -> float:
|
||||
"""
|
||||
Convert input angle into milliradians (mrad).
|
||||
|
||||
This function takes an angle as input and determines its likely unit,
|
||||
converting it to milliradians (mrad) if necessary. If the input value
|
||||
is less than 1, it is assumed to be in radians and is converted to
|
||||
mrad. If the input value falls between predefined minimum and
|
||||
maximum values for mrad, it is assumed to be in degrees and thus
|
||||
converted to mrad using the degrees-to-radians conversion factor.
|
||||
|
||||
For input values that don't match these scenarios, it assumes
|
||||
that the input is already in mrad and returns it unchanged.
|
||||
|
||||
Arguments:
|
||||
bragg_angle (float): The input Bragg angle, which can be in
|
||||
radians, degrees, or milliradians.
|
||||
|
||||
Returns:
|
||||
float: The Bragg angle converted into milliradians (mrad).
|
||||
"""
|
||||
if bragg_angle < 1: # Likely the input angle is in radians
|
||||
return bragg_angle * 1000
|
||||
if 3 < bragg_angle < 25: # Likely input angle is in degrees
|
||||
return np.deg2rad(bragg_angle) * 1000
|
||||
return bragg_angle # Already in mrad
|
||||
|
||||
|
||||
# Core Functions
|
||||
def validate_energy(energy_ev):
|
||||
"""
|
||||
Validates the energy value to ensure it falls within the acceptable range. The function
|
||||
converts the provided energy from keV to eV if the input value is less than 1/1000 of the
|
||||
maximum energy value. It then checks whether the energy is within the defined bounds.
|
||||
If the energy value is outside the acceptable range, the function raises a ValueError.
|
||||
|
||||
Args:
|
||||
energy_ev (float): The energy value in eV or keV to be validated. If this value is
|
||||
smaller than 1/1000 of the maximum allowed energy (in eV), it will be multiplied
|
||||
by 1000 to convert it from keV to eV.
|
||||
|
||||
Returns:
|
||||
float: The validated energy value in eV that falls within the acceptable range.
|
||||
|
||||
Raises:
|
||||
ValueError: If the energy value is outside the defined range of
|
||||
[MIN_ENERGY_EV, MAX_ENERGY_EV].
|
||||
"""
|
||||
if energy_ev < EnergyDefaults.max_energy_ev / 1000: # Assuming the input is in keV.
|
||||
energy_ev *= 1000
|
||||
if not EnergyDefaults.min_energy_ev <= energy_ev <= EnergyDefaults.max_energy_ev:
|
||||
raise ValueError(
|
||||
f"Energy of {energy_ev} eV is outside the valid range "
|
||||
f"({EnergyDefaults.min_energy_ev} eV to {EnergyDefaults.max_energy_ev} eV)"
|
||||
)
|
||||
return energy_ev
|
||||
|
||||
|
||||
def convert_from_bragg(
|
||||
bragg_angle_mrad: float, temp=120, print_result: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Convert the Bragg angle to wavelength and energy, returning the result as a dictionary.
|
||||
|
||||
This function converts a given Bragg angle (in milliradians) into the corresponding
|
||||
wavelength and energy values, and returns them in a dictionary format. The function
|
||||
also supports optional printing of the calculated results.
|
||||
|
||||
Args:
|
||||
bragg_angle_mrad (float): The Bragg angle in milliradians to be converted.
|
||||
print_result (bool): Whether to print the conversion result. Defaults to False.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the following keys:
|
||||
- 'energy_ev': Energy in electronvolts.
|
||||
- 'wavelength': Wavelength corresponding to the input angle.
|
||||
- 'bragg_angle_mrad': Input Bragg angle in milliradians.
|
||||
"""
|
||||
bragg_angle_mrad = convert_input_angle_to_mrad(bragg_angle_mrad)
|
||||
wavelength = float(calculate_wavelength_from_angle(bragg_angle_mrad, temp=temp))
|
||||
energy_ev = float(calculate_energy_from_wavelength(wavelength))
|
||||
result = create_conversion_result(energy_ev, wavelength, bragg_angle_mrad)
|
||||
if print_result:
|
||||
print_conversion_result(result)
|
||||
return result
|
||||
|
||||
|
||||
def convert_from_energy(energy_ev: float, temp=120, print_result: bool = False) -> dict:
|
||||
"""
|
||||
Convert energy in electron volts (eV) to wavelength and Bragg angle in milliradians
|
||||
(mrad). This method validates the given energy, calculates corresponding properties,
|
||||
and optionally prints the result.
|
||||
|
||||
Args:
|
||||
energy_ev: Energy value in electron volts (float) to be converted.
|
||||
print_result: Flag indicating whether to print the resulting
|
||||
conversion details (bool). Defaults to False.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the following key-value pairs:
|
||||
- "energy_ev" (float): Validated energy in eV.
|
||||
- "wavelength" (float): Calculated wavelength in meters.
|
||||
- "bragg_angle_mrad" (float): Calculated Bragg angle in mrad.
|
||||
"""
|
||||
energy_ev = validate_energy(energy_ev)
|
||||
wavelength = calculate_wavelength_from_energy(energy_ev)
|
||||
bragg_angle_mrad = float(
|
||||
calculate_bragg_angle_from_wavelength(wavelength, temp=temp)
|
||||
)
|
||||
result = create_conversion_result(energy_ev, wavelength, bragg_angle_mrad)
|
||||
if print_result:
|
||||
print_conversion_result(result)
|
||||
return result
|
||||
|
||||
|
||||
def convert_from_wavelength(
|
||||
wavelength: float,
|
||||
temp: float = 120,
|
||||
print_result: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
Convert a given wavelength value into corresponding energy, Bragg angle, and
|
||||
generate a result dictionary.
|
||||
|
||||
The function processes a wavelength value, checks its validity against a
|
||||
permitted range, calculates corresponding energy and Bragg angle, and
|
||||
formats the results into a dictionary. Optionally, the function can print
|
||||
the result.
|
||||
|
||||
Parameters:
|
||||
wavelength: float
|
||||
The input wavelength value in Angstroms to be converted. Should
|
||||
fall within the permitted wavelength range.
|
||||
print_result: bool
|
||||
Optional flag indicating whether to print the conversion result.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
dict
|
||||
A dictionary containing the energy (electron-volts), wavelength
|
||||
(Angstroms), and Bragg angle (milliradians). If the wavelength is
|
||||
outside of the permitted range, returns None.
|
||||
"""
|
||||
energy_ev = calculate_energy_from_wavelength(wavelength)
|
||||
bragg_angle_mrad = float(
|
||||
calculate_bragg_angle_from_wavelength(wavelength, temp=temp)
|
||||
)
|
||||
result = create_conversion_result(energy_ev, wavelength, bragg_angle_mrad)
|
||||
if print_result:
|
||||
print_conversion_result(result)
|
||||
return result
|
||||
|
||||
|
||||
def calc_perp_position(
|
||||
energy_ev: float,
|
||||
print_result: bool = False,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the perpendicular motor position based on provided energy in electron-volts (eV).
|
||||
|
||||
This function computes the perpendicular motor position using the given energy value in
|
||||
electron-volts. The calculation is based on the Bragg angle derived from the energy. An optional
|
||||
parameter allows printing the result during execution.
|
||||
|
||||
Parameters:
|
||||
energy_ev (float): The energy value in electron-volts used for the calculation.
|
||||
print_result (bool): Flag to determine whether to print the computed perpendicular offset.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
float: The computed perpendicular position.
|
||||
|
||||
Raises:
|
||||
None
|
||||
"""
|
||||
result = convert_from_energy(energy_ev, print_result=False)
|
||||
bragg_angle_rad = result["bragg_angle_mrad"] / 1000
|
||||
perp_offset = float(EnergyDefaults.beam_offset / (2 * np.cos(bragg_angle_rad))) - 3
|
||||
if print_result:
|
||||
print(f"Perp = {perp_offset: .4f}")
|
||||
return perp_offset
|
||||
|
||||
def calc_scam_microns(pixels, zoom = 1000):
|
||||
return pixels/(0.5208 * np.exp(0.002586 * zoom))
|
||||
|
||||
def calc_scam_microns(pixels, zoom=1000):
|
||||
"""Convert pixels to microns for the sample camera"""
|
||||
return float(pixels / (CamConversion.a * np.exp(CamConversion.b * zoom)))
|
||||
|
||||
def calc_bsccam_microns(pixels):
|
||||
"""Convert pixels to microns for the BSC camera"""
|
||||
return pixels*20
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
"""Start of a beamline health checker"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Callable
|
||||
from datetime import datetime
|
||||
|
||||
from bec_lib.device import Signal, Positioner
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Status Enum
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
class Status(Enum):
|
||||
"""Define standard statuses"""
|
||||
|
||||
OK = 0
|
||||
WARNING = 1
|
||||
ERROR = 2
|
||||
UNKNOWN = 3
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
return {
|
||||
Status.OK: "green",
|
||||
Status.WARNING: "yellow",
|
||||
Status.ERROR: "red",
|
||||
Status.UNKNOWN: "blue",
|
||||
}[self]
|
||||
|
||||
@property
|
||||
def color_scilog(self):
|
||||
return {Status.OK: "green", Status.WARNING: "", Status.ERROR: "red", Status.UNKNOWN: ""}[
|
||||
self
|
||||
]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Health Result Object
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthCheckResult:
|
||||
"""Define the output of the health check"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
status: Status
|
||||
|
||||
value: Any = None
|
||||
|
||||
message: str = ""
|
||||
|
||||
category: str = "general"
|
||||
|
||||
def __str__(self):
|
||||
|
||||
if self.status == Status.OK:
|
||||
return f"[{self.status.name}] {self.description}"
|
||||
|
||||
return f"[{self.status.name}] " f"{self.description}: {self.message}"
|
||||
|
||||
def formatted_message(self):
|
||||
if self.status == Status.OK:
|
||||
return f"[{self.status.name}] {self.name}"
|
||||
|
||||
return f"[{self.status.name}] " f"{self.description}: {self.message}"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Send to SciLog
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
def send_to_scilog(results):
|
||||
"""Make a scilog entry of the health check"""
|
||||
|
||||
counts = {Status.OK: 0, Status.WARNING: 0, Status.ERROR: 0, Status.UNKNOWN: 0}
|
||||
|
||||
for result in results:
|
||||
counts[result.status] += 1
|
||||
timestamp = datetime.now().strftime("%Y/%m/%d %H:%M")
|
||||
|
||||
msg = bec.messaging.scilog.new()
|
||||
|
||||
msg.add_text(f"Beamline Health Summary {timestamp}", bold=True)
|
||||
|
||||
msg.add_text("\n")
|
||||
for status, count in counts.items():
|
||||
msg.add_text(f"{status.name:<10}: {count}", bold=True)
|
||||
msg.add_text("\n")
|
||||
|
||||
for result in results:
|
||||
msg.add_text(
|
||||
result.formatted_message(),
|
||||
# bold=result.status != Status.OK,
|
||||
color=result.status.color_scilog,
|
||||
)
|
||||
msg.add_text("\n")
|
||||
msg.add_tags(["beamline health check"])
|
||||
msg.send()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeamlineHealthConfig:
|
||||
"""Define some rules to check against"""
|
||||
|
||||
signal_rules: dict[str, Callable] = field(
|
||||
default_factory=lambda: {"cam": lambda x: x != 0, "bpm": lambda x: x != 0}
|
||||
)
|
||||
|
||||
motor_tolerances: dict[str, float] = field(
|
||||
default_factory=lambda: {
|
||||
# examples
|
||||
# "mono_theta": 0.001,
|
||||
# "detector_z": 0.1,
|
||||
}
|
||||
)
|
||||
|
||||
default_motor_tolerance: float = 0.02
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Device Collection
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_devices():
|
||||
"""Return a list of all the beamline devices"""
|
||||
return list(dev.items())
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Signal Checks
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
def check_signals(devices, config: BeamlineHealthConfig):
|
||||
"""Check the signal devices"""
|
||||
|
||||
results = []
|
||||
|
||||
signal_devices = [(name, obj) for name, obj in devices if isinstance(obj, Signal)]
|
||||
|
||||
for name, obj in signal_devices:
|
||||
|
||||
try:
|
||||
data = obj.read()
|
||||
actual = data[name]["value"]
|
||||
description = obj.description
|
||||
|
||||
except Exception as e:
|
||||
|
||||
results.append(
|
||||
HealthCheckResult(
|
||||
name=name,
|
||||
description=name,
|
||||
status=Status.UNKNOWN,
|
||||
message=f"Failed to read signal: {e}",
|
||||
category="signals",
|
||||
)
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
matched = False
|
||||
|
||||
for keyword, rule in config.signal_rules.items():
|
||||
|
||||
if keyword in name:
|
||||
|
||||
matched = True
|
||||
|
||||
try:
|
||||
passed = rule(actual)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
results.append(
|
||||
HealthCheckResult(
|
||||
name=name,
|
||||
description=name,
|
||||
status=Status.UNKNOWN,
|
||||
value=actual,
|
||||
message=f"Rule evaluation failed: {e}",
|
||||
category="signals",
|
||||
)
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
if passed:
|
||||
|
||||
results.append(
|
||||
HealthCheckResult(
|
||||
name=name,
|
||||
description=description,
|
||||
status=Status.OK,
|
||||
value=actual,
|
||||
category="signals",
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
results.append(
|
||||
HealthCheckResult(
|
||||
name=name,
|
||||
description=description,
|
||||
status=Status.ERROR,
|
||||
value=actual,
|
||||
message=f"Signal value {actual} failed validation",
|
||||
category="signals",
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
if not matched:
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Motor Checks
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
def check_motors(devices, config: BeamlineHealthConfig):
|
||||
"""Check the standard motor devices"""
|
||||
|
||||
results = []
|
||||
|
||||
motor_devices = [(name, obj) for name, obj in devices if isinstance(obj, Positioner)]
|
||||
|
||||
for name, obj in motor_devices:
|
||||
|
||||
try:
|
||||
|
||||
data = obj.read()
|
||||
|
||||
description = obj.description
|
||||
|
||||
actual = data[name]["value"]
|
||||
|
||||
error_code = obj.motor_status.get()
|
||||
|
||||
move_state = obj.motor_is_moving.get()
|
||||
|
||||
except Exception as e:
|
||||
|
||||
results.append(
|
||||
HealthCheckResult(
|
||||
name=name,
|
||||
description=name,
|
||||
status=Status.UNKNOWN,
|
||||
message=f"Failed to read motor: {e}",
|
||||
category="motors",
|
||||
)
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Error state
|
||||
# -----------------------------------------------------------
|
||||
|
||||
if error_code != 0:
|
||||
|
||||
results.append(
|
||||
HealthCheckResult(
|
||||
name=name,
|
||||
description=description,
|
||||
status=Status.ERROR,
|
||||
value=error_code,
|
||||
message=f"motor error code: {error_code}",
|
||||
category="motors",
|
||||
)
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Moving state
|
||||
# -----------------------------------------------------------
|
||||
|
||||
if move_state != 0:
|
||||
|
||||
results.append(
|
||||
HealthCheckResult(
|
||||
name=name,
|
||||
description=description,
|
||||
status=Status.WARNING,
|
||||
value=move_state,
|
||||
message="motor is currently moving",
|
||||
category="motors",
|
||||
)
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Setpoint comparison
|
||||
# -----------------------------------------------------------
|
||||
|
||||
sp_key = f"{name}_user_setpoint"
|
||||
|
||||
if sp_key in data:
|
||||
|
||||
setpoint = data[sp_key]["value"]
|
||||
|
||||
diff = abs(actual - setpoint)
|
||||
|
||||
tolerance = config.motor_tolerances.get(name, config.default_motor_tolerance)
|
||||
|
||||
if diff > tolerance:
|
||||
|
||||
results.append(
|
||||
HealthCheckResult(
|
||||
name=name,
|
||||
description=description,
|
||||
status=Status.WARNING,
|
||||
value=diff,
|
||||
message=(
|
||||
f"Setpoint {setpoint:.5g} differs "
|
||||
f"from readback {actual:.5g} "
|
||||
f"by {diff:.4g}"
|
||||
),
|
||||
category="motors",
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
results.append(
|
||||
HealthCheckResult(
|
||||
name=name,
|
||||
description=description,
|
||||
status=Status.OK,
|
||||
value=actual,
|
||||
category="motors",
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
results.append(
|
||||
HealthCheckResult(
|
||||
name=name,
|
||||
description=description,
|
||||
status=Status.UNKNOWN,
|
||||
message="No setpoint available",
|
||||
category="motors",
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Main Check Entry Point
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
def check2(config: BeamlineHealthConfig | None = None):
|
||||
"""Perform the checks"""
|
||||
if config is None:
|
||||
config = BeamlineHealthConfig()
|
||||
|
||||
devices = get_devices()
|
||||
|
||||
results = []
|
||||
|
||||
results.extend(check_signals(devices, config))
|
||||
|
||||
results.extend(check_motors(devices, config))
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Sort by severity
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
results.sort(key=lambda r: r.status.value)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Summary Printer
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
def summary_text(results):
|
||||
"""Summarise the results in a text table"""
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
n_ok = sum(r.status == Status.OK for r in results)
|
||||
|
||||
n_warn = sum(r.status == Status.WARNING for r in results)
|
||||
|
||||
n_err = sum(r.status == Status.ERROR for r in results)
|
||||
|
||||
n_unknown = sum(r.status == Status.UNKNOWN for r in results)
|
||||
|
||||
return (
|
||||
f"==========================================\n"
|
||||
f"Beamline Health Check at {timestamp}\n"
|
||||
f"==========================================\n"
|
||||
f"OK : {n_ok}\n"
|
||||
f"WARNING : {n_warn}\n"
|
||||
f"ERROR : {n_err}\n"
|
||||
f"UNKNOWN : {n_unknown}\n"
|
||||
"==========================================\n"
|
||||
)
|
||||
# return "\n".join(lines)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Filter results
|
||||
# -------------------------------------------------------------------
|
||||
def filter_results(results, statuses=None):
|
||||
"""Filter the results"""
|
||||
if statuses is None:
|
||||
return results
|
||||
|
||||
return [r for r in results if r.status in statuses]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# CLI Entry Point
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_check(show_all=False):
|
||||
"""Runs the checks and outputs the results"""
|
||||
|
||||
results = check2()
|
||||
|
||||
print(summary_text(results))
|
||||
|
||||
problem_results = filter_results(
|
||||
results, statuses={Status.WARNING, Status.ERROR, Status.UNKNOWN}
|
||||
)
|
||||
send_to_scilog(results)
|
||||
|
||||
if not show_all:
|
||||
results = filter_results(results, statuses={Status.WARNING, Status.ERROR, Status.UNKNOWN})
|
||||
for result in results:
|
||||
print(result)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Planner dependencies"""
|
||||
def planner_deps():
|
||||
"""Define the dependencies between beamline positions"""
|
||||
return {
|
||||
("bs_z", "samp"): [
|
||||
("aerotech_x", "out"),
|
||||
("diag_y", "out"),
|
||||
("coll_y", "out"),
|
||||
],
|
||||
("aerotech_x", "in"): [
|
||||
("diag_y", "out"),
|
||||
("bs_z", "safe"),
|
||||
],
|
||||
("aerotech_x", "out"): [
|
||||
("diag_y", "out"),
|
||||
("bs_z", "safe"),
|
||||
],
|
||||
("diag_y", "scint"): [
|
||||
("aerotech_x", "out"),
|
||||
("bs_z", "safe"),
|
||||
("cryo_pos", "out"),
|
||||
],
|
||||
("diag_y", "i1"): [
|
||||
("aerotech_x", "out"),
|
||||
("bs_z", "safe"),
|
||||
("cryo_pos", "out"),
|
||||
],
|
||||
("bs_pos", "out"): [("bs_z", "safe")],
|
||||
("bs_pos", "in"): [("bs_z", "safe")],
|
||||
("diag_y", "out"): [("bs_z", "safe")],
|
||||
("diag_y", "park"): [("bs_z", "safe")],
|
||||
("coll_y", "out"): [("bs_z", "safe")],
|
||||
("coll_y", "park"): [("bs_z", "safe")],
|
||||
("coll_y", "in"): [("bs_z", "safe")],
|
||||
("coll_y", "intermediate"): [("bs_z", "safe")],
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
This module manages the initialization of devices."""
|
||||
|
||||
# from guards import attach_guards
|
||||
# from policies import attach_policies
|
||||
# from build_devices import build_devices
|
||||
|
||||
|
||||
class DeviceManager:
|
||||
"""Class for building devices and attaching safety guards and policies."""
|
||||
|
||||
@staticmethod
|
||||
def initialize_devices(state_devices_file, rest_devices_file, mock_devices):
|
||||
"""
|
||||
Initializes sample environment devices from the specified file.
|
||||
"""
|
||||
devices = build_devices(state_devices_file, mock_devices)
|
||||
rest_devices = build_devices(rest_devices_file, mock_devices)
|
||||
devices.update(rest_devices)
|
||||
attach_guards(devices)
|
||||
attach_policies(devices)
|
||||
return devices
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Enums for beamline states"""
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class BeamlineState(str, Enum):
|
||||
"""List of beamline states"""
|
||||
ROBOT_SAMPLE_EXCHANGE = "robot_sample_exchange"
|
||||
SAMPLE_ALIGNMENT = "sample_alignment"
|
||||
DATA_COLLECTION = "data_collection"
|
||||
DC_XRF = "DC_XRF"
|
||||
MANUAL_SAMPLE_EXCHANGE = "manual_sample_exchange"
|
||||
BEAM_VISUALISATION = "beam_visualisation"
|
||||
FLUX_MEASUREMENT = "flux_measurement"
|
||||
BEAMSTOP_ALIGNMENT = "beamstop_alignment"
|
||||
MAINTENANCE = "maintenance"
|
||||
XTAL_SNAPSHOT = "xtal_snapshot"
|
||||
|
||||
|
||||
class TemperatureMode(str, Enum):
|
||||
"""List of temperature modes"""
|
||||
CRYO = "cryo"
|
||||
ROOM_TEMP = "room_temp"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Setup guards for devices."""
|
||||
|
||||
|
||||
class GuardViolation(Exception):
|
||||
"""Raised when a guarded move is not allowed."""
|
||||
|
||||
|
||||
class AtPositionGuard:
|
||||
"""Guard that checks if a device is in a specific position."""
|
||||
|
||||
def __init__(self, device, position):
|
||||
self.device = device
|
||||
self.pos = position
|
||||
|
||||
def check(self):
|
||||
"""Check if the device is in the specified position."""
|
||||
if self.device.pos != self.pos:
|
||||
raise GuardViolation(
|
||||
f"{self.device.bec_name} must be in the '{self.pos}' position"
|
||||
)
|
||||
# print("move allowed")
|
||||
return True
|
||||
|
||||
def requirement(self):
|
||||
"""Return the requirement for the guard."""
|
||||
return (self.device.bec_name, self.pos)
|
||||
|
||||
|
||||
class MinMaxGuard:
|
||||
"""Guard that checks if a device is within a specific range."""
|
||||
|
||||
def __init__(self, device, limit_value, direction):
|
||||
self.device = device
|
||||
self.limit_value = limit_value
|
||||
self.direction = direction # direction: 'max' or 'min'
|
||||
|
||||
def check(self):
|
||||
"""Check if the device is within the specified range."""
|
||||
if self.direction == "less_than":
|
||||
if not (self.device.actual - self.device.tol) <= self.limit_value:
|
||||
raise GuardViolation(
|
||||
f"{self.device.bec_name} must be less than or equal to {self.limit_value} mm"
|
||||
)
|
||||
elif self.direction == "more_than":
|
||||
if not (self.device.actual + self.device.tol) >= self.limit_value:
|
||||
raise GuardViolation(
|
||||
f"{self.device.bec_name} must be greater than or equal to {self.limit_value} mm"
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid direction '{self.direction}'. Use 'less_than' or 'more_than'."
|
||||
)
|
||||
|
||||
# print("move allowed")
|
||||
return True
|
||||
|
||||
def requirement(self):
|
||||
"""Return the requirement for the guard."""
|
||||
# planner cannot handle numeric constraints directly
|
||||
# return None -> planner ignores
|
||||
return None
|
||||
|
||||
|
||||
def guards_setup(d):
|
||||
"""Define guards for devices."""
|
||||
guards = {}
|
||||
guards["bs_safe"] = AtPositionGuard(d["bs_z"], position="safe")
|
||||
guards["bs_max_blin"] = MinMaxGuard(
|
||||
d["bs_z"], direction="less_than", limit_value=d["bs_z"].positions["max_blin"]
|
||||
)
|
||||
guards["bs_work_min"] = MinMaxGuard(
|
||||
d["bs_z"], direction="more_than", limit_value=d["bs_z"].positions["work_min"]
|
||||
)
|
||||
guards["bs_pos_in"] = AtPositionGuard(d["bs_pos"], position="in")
|
||||
guards["gonx_out"] = MinMaxGuard(
|
||||
d["aerotech_x"], direction="less_than", limit_value=d["aerotech_x"].positions["out"]
|
||||
)
|
||||
guards["gonx_safe"] = AtPositionGuard(d["aerotech_x"], position="safe")
|
||||
guards["diag_y_out"] = MinMaxGuard(
|
||||
d["diag_y"], direction="less_than", limit_value=d["diag_y"].positions["out"]
|
||||
)
|
||||
guards["coll_y_out"] = MinMaxGuard(
|
||||
d["coll_y"], direction="less_than", limit_value=d["coll_y"].positions["out"]
|
||||
)
|
||||
return guards
|
||||
|
||||
|
||||
def attach_guards(d):
|
||||
"""Attach guards to devices."""
|
||||
g = guards_setup(d)
|
||||
d["diag_y"].guards.append(g["bs_work_min"].check)
|
||||
d["bl_pos"].guards.append(g["bs_max_blin"].check)
|
||||
d["bs_pos"].guards.append(g["bs_safe"].check)
|
||||
d["bs_z"].guards.append(g["bs_pos_in"].check)
|
||||
d["coll_y"].guards.append(g["bs_work_min"].check)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Initialise sample environment devices and beamline states"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# from devices_manager import DeviceManager
|
||||
# from beamline_state_manager import DefineStatesManager
|
||||
# from dependencies import planner_deps
|
||||
# from beamline_planner import StateChangePlanner
|
||||
|
||||
@dataclass
|
||||
class Environment:
|
||||
|
||||
device_mocks: dict[str, bool] = None
|
||||
|
||||
def __post_init__(self):
|
||||
|
||||
if self.device_mocks is None:
|
||||
self.device_mocks = {
|
||||
"aerotech_x": "mock",
|
||||
"aerotech_y": "mock",
|
||||
"aerotech_z": "mock",
|
||||
"aerotech_u": "mock",
|
||||
"bl_bright": "mock",
|
||||
"bl_pos": "mock",
|
||||
"bs_pos": "mock",
|
||||
"bs_z": "mock",
|
||||
"coll_y": "mock",
|
||||
"cryo_pos": "mock",
|
||||
"det_cov": "mock",
|
||||
"diag_y": "mock",
|
||||
"fl_bright": "mock",
|
||||
"smargon_x": "mock",
|
||||
"smargon_y": "mock",
|
||||
"smargon_z": "mock",
|
||||
"smargon_chi": "mock",
|
||||
"smargon_phi": "mock",
|
||||
"xrf_pos": "mock",
|
||||
}
|
||||
|
||||
mocks = sorted(
|
||||
name
|
||||
for name, backend in self.device_mocks.items()
|
||||
if backend == "mock"
|
||||
)
|
||||
|
||||
reals = sorted(
|
||||
name
|
||||
for name, backend in self.device_mocks.items()
|
||||
if backend == "real"
|
||||
)
|
||||
|
||||
print(f"Mock devices ({len(mocks)}): {mocks}")
|
||||
print(f"Real devices ({len(reals)}): {reals}")
|
||||
|
||||
devdir = "/sls/x06da/config/bec/production/pxiii_bec/pxiii_bec/device_configs/"
|
||||
|
||||
state_devices_file: str = devdir + "pxiii-state-devices.yaml"
|
||||
rest_devices_file: str = devdir + "pxiii-rest-devices.yaml"
|
||||
states_file: str = devdir + "beamline_states.yaml"
|
||||
|
||||
@property
|
||||
def mock_devices(self):
|
||||
mock_names = set()
|
||||
for name, device in self.device_mocks.items():
|
||||
if device == "mock":
|
||||
mock_names.add(name)
|
||||
return mock_names
|
||||
|
||||
def init_beamline_environment():
|
||||
"""
|
||||
Initializes the beamline with real or mock devices.
|
||||
"""
|
||||
|
||||
env = Environment()
|
||||
|
||||
# Initialize devices
|
||||
device_manager = DeviceManager()
|
||||
devices = device_manager.initialize_devices(
|
||||
env.state_devices_file,
|
||||
env.rest_devices_file,
|
||||
env.mock_devices
|
||||
)
|
||||
|
||||
# Initialize states
|
||||
state_manager = DefineStatesManager()
|
||||
states, allow_modifiers = state_manager.initialize_states(env.states_file)
|
||||
|
||||
# Setup dependencies
|
||||
deps = planner_deps()
|
||||
|
||||
# Setup planner
|
||||
planner = StateChangePlanner(devices, states, allow_modifiers, deps)
|
||||
print("Initializing beamline state planner")
|
||||
return devices, planner
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Used for checking device positions match current state"""
|
||||
class DeviceMatcher:
|
||||
"""Class for checking device positions match current state"""
|
||||
def __init__(self):
|
||||
self._rules = {}
|
||||
|
||||
def register(self, device_name, func):
|
||||
"""Register a matching function for a device."""
|
||||
self._rules[device_name] = func
|
||||
|
||||
def matches(self, device_name, device, expected):
|
||||
"""Return True if device matches expected state."""
|
||||
if expected is None:
|
||||
return True # "don't care"
|
||||
|
||||
if device_name in self._rules:
|
||||
return self._rules[device_name](device, expected)
|
||||
|
||||
# default fallback
|
||||
return device.is_at(expected)
|
||||
|
||||
def explain(self, device_name, device, expected):
|
||||
"""Return True if device matches expected state."""
|
||||
val = device.readback.get()
|
||||
|
||||
if device_name in self._rules:
|
||||
ok = self._rules[device_name](device, expected)
|
||||
else:
|
||||
ok = device.is_at(expected)
|
||||
|
||||
return ok, val
|
||||
|
||||
def nonzero_is_on(device, expected, eps=1e-6):
|
||||
"""Define that anything > 0 is on"""
|
||||
val = device.actual
|
||||
|
||||
if expected == "off":
|
||||
return abs(val) < eps
|
||||
if expected == "on":
|
||||
return val > eps
|
||||
|
||||
return abs(val - expected) < eps
|
||||
|
||||
|
||||
# def threshold_rule(param_name):
|
||||
# def _rule(device, expected):
|
||||
# val = device.actual
|
||||
#
|
||||
# if expected == param_name:
|
||||
# threshold = device.position
|
||||
# return val >= threshold
|
||||
#
|
||||
# return device.is_at(expected)
|
||||
#
|
||||
# return _rule
|
||||
#
|
||||
#
|
||||
# def within_tolerance(tol):
|
||||
# def _rule(device, expected):
|
||||
# val = device.readback.get()
|
||||
# return abs(val - expected) < tol
|
||||
#
|
||||
# return _rule
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Get data from an h5 file or BEC history and perform fitting."""
|
||||
|
||||
import numpy as np
|
||||
from lmfit.models import (
|
||||
GaussianModel,
|
||||
LorentzianModel,
|
||||
VoigtModel,
|
||||
ConstantModel,
|
||||
LinearModel,
|
||||
)
|
||||
from scipy.ndimage import gaussian_filter1d
|
||||
import h5py
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
|
||||
def create_fit_parameters(
|
||||
deriv: bool = False,
|
||||
model: str = "Voigt",
|
||||
baseline: str = "Linear",
|
||||
smoothing: None = None,
|
||||
):
|
||||
"""Store the fit parameters in a dictionary."""
|
||||
# map input model to lmfit model name
|
||||
model_mappings = {
|
||||
"Gaussian": GaussianModel,
|
||||
"Lorentzian": LorentzianModel,
|
||||
"Voigt": VoigtModel,
|
||||
"Constant": ConstantModel,
|
||||
"Linear": LinearModel,
|
||||
}
|
||||
return {
|
||||
"deriv": deriv,
|
||||
"model": model_mappings[model],
|
||||
"baseline": model_mappings[baseline],
|
||||
"smoothing": smoothing,
|
||||
}
|
||||
|
||||
|
||||
def get_data_from_h5(signal_name: str = "lu_bpmsum"):
|
||||
"""Get data from an h5 file."""
|
||||
with h5py.File("scan_676.h5", "r") as f:
|
||||
entry = f["entry"]["collection"]
|
||||
y_data = entry["devices"][signal_name][signal_name]["value"][:]
|
||||
motor_data = entry["metadata"]["bec"]
|
||||
motor_name = motor_data["scan_motors"][0].decode()
|
||||
scan_number = motor_data["scan_number"][()]
|
||||
x_data = entry["devices"][motor_name][motor_name]["value"][:]
|
||||
return {
|
||||
"x_data": x_data,
|
||||
"y_data": y_data,
|
||||
"signal_name": signal_name,
|
||||
"motor_name": motor_name,
|
||||
"scan_number": str(scan_number),
|
||||
}
|
||||
|
||||
|
||||
def get_data_from_history(
|
||||
history_index: int,
|
||||
signal_name: str = "lu_bpmsum",
|
||||
):
|
||||
"""Read data from the BEC history and return the X and Y data as arrays."""
|
||||
scan = bec.history[history_index]
|
||||
md = scan.metadata["bec"]
|
||||
motor_name = md["scan_motors"][0].decode()
|
||||
scan_number = md["scan_number"]
|
||||
x_data = scan.devices[motor_name][motor_name].read()["value"]
|
||||
y_data = scan.devices[signal_name][signal_name].read()["value"]
|
||||
return {
|
||||
"signal_name": signal_name,
|
||||
"x_data": x_data,
|
||||
"y_data": y_data,
|
||||
"motor_name": motor_name,
|
||||
"scan_number": scan_number,
|
||||
}
|
||||
|
||||
|
||||
def process_data(data, fit_params):
|
||||
"""
|
||||
Process the signal data for fitting based on derivative or smoothing.
|
||||
"""
|
||||
smoothing, deriv = fit_params["smoothing"], fit_params["deriv"]
|
||||
signal_name = data["signal_name"]
|
||||
y_data = data["y_data"]
|
||||
|
||||
if deriv:
|
||||
if smoothing:
|
||||
y_smooth = gaussian_filter1d(y_data, smoothing)
|
||||
fitting_data = np.gradient(y_smooth)
|
||||
signal_name = f"Derivative of smoothed {signal_name}"
|
||||
else:
|
||||
fitting_data = np.gradient(y_data)
|
||||
signal_name = f"Derivative of {signal_name}"
|
||||
elif smoothing and smoothing > 0.01:
|
||||
fitting_data = gaussian_filter1d(y_data, smoothing)
|
||||
signal_name = f"Smoothed {signal_name}"
|
||||
else:
|
||||
fitting_data = y_data
|
||||
|
||||
updated_data = {
|
||||
"y_to_fit": fitting_data,
|
||||
"signal_name": signal_name,
|
||||
}
|
||||
data.update(updated_data)
|
||||
return data
|
||||
|
||||
|
||||
def fit(data, fit_params):
|
||||
"""Fit a signal to a model and return the fitting results."""
|
||||
# Create the model
|
||||
peak_model = fit_params["model"](prefix="peak_")
|
||||
baseline_model = fit_params["baseline"](prefix="base_")
|
||||
full_model = peak_model + baseline_model
|
||||
|
||||
# Prepare data
|
||||
processed_data = process_data(data, fit_params)
|
||||
params = full_model.make_params()
|
||||
y_min = np.min(processed_data["y_to_fit"])
|
||||
|
||||
# Configure baseline parameters
|
||||
if fit_params["baseline"] == ConstantModel:
|
||||
params["base_c"].set(value=y_min)
|
||||
elif fit_params["baseline"] == LinearModel:
|
||||
params["base_intercept"].set(value=y_min)
|
||||
params["base_slope"].set(value=0)
|
||||
|
||||
# Add peak-specific parameters
|
||||
params.update(
|
||||
peak_model.guess(processed_data["y_to_fit"], x=processed_data["x_data"])
|
||||
)
|
||||
|
||||
# Perform the fitting
|
||||
lmfit_result = full_model.fit(
|
||||
processed_data["y_to_fit"], params, x=processed_data["x_data"]
|
||||
)
|
||||
|
||||
# Find the X that gives the max Y
|
||||
max_index = np.argmax(processed_data["y_to_fit"])
|
||||
x_max = processed_data["x_data"][max_index]
|
||||
|
||||
# Generate data for a smoothed fit curve
|
||||
fit_xdata = np.linspace(np.min(data["x_data"]), np.max(data["x_data"]), 500)
|
||||
fit_ydata = lmfit_result.eval(x=fit_xdata, params=lmfit_result.params)
|
||||
|
||||
# Collect results
|
||||
return {
|
||||
"model": fit_params["model"].__name__,
|
||||
"fwhm": lmfit_result.params["peak_fwhm"].value,
|
||||
"centre": lmfit_result.best_values["peak_center"],
|
||||
"height": lmfit_result.params["peak_height"].value,
|
||||
"chi_sq": lmfit_result.chisqr,
|
||||
"lmfit_result": lmfit_result,
|
||||
"x_max": x_max,
|
||||
"fit_xdata": fit_xdata,
|
||||
"fit_ydata": fit_ydata,
|
||||
}
|
||||
|
||||
|
||||
def plot_fitted_data(data, fit_result):
|
||||
"""Plot the original data and the fitted model."""
|
||||
plt.plot(data["x_data"], data["y_to_fit"], label="Data")
|
||||
plt.plot(fit_result['fit_xdata'], fit_result['fit_ydata'], label="Fit")
|
||||
plt.xlabel(data["motor_name"])
|
||||
plt.ylabel(data["signal_name"])
|
||||
plt.title(f"Scan {data['scan_number']}, fitted with {fit_result['model']}")
|
||||
plt.grid(True)
|
||||
plt.legend()
|
||||
plt.show()
|
||||
|
||||
|
||||
def select_bec_window(dock_area_name="Fitting"):
|
||||
"""Check to see if the fitting results dock is already open and re-create it if not"""
|
||||
open_docks = bec.gui.windows
|
||||
if open_docks.get(dock_area_name) is None:
|
||||
dock_area = bec.gui.new(dock_area_name)
|
||||
# wf = dock_area.new("Plot").new(bec.gui.available_widgets.Waveform)
|
||||
wf = dock_area.new(widget='Waveform', object_name='Plot')
|
||||
text_box = dock_area.new(widget='TextBox', object_name="Results", where="bottom")
|
||||
else:
|
||||
wf = bec.gui.Fitting.Plot
|
||||
text_box = bec.gui.Fitting.Results
|
||||
return wf, text_box
|
||||
|
||||
|
||||
def plot_live_data_bec(
|
||||
motor_name,
|
||||
signal_name,
|
||||
window_name="Fitting"
|
||||
):
|
||||
"""
|
||||
Plotting live data for motor and signal using BEC.
|
||||
|
||||
This function plots live data from a specified motor and signal.
|
||||
It clears the current plot window, sets its title, labels the axes
|
||||
with the provided motor and signal names, and initializes live plotting
|
||||
on the given signal against the motor.
|
||||
|
||||
Args:
|
||||
motor_name (str): The name of the motor to be used as the x-axis.
|
||||
signal_name (str): The name of the signal to be used as the y-axis.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
wf, text_box = select_bec_window(window_name)
|
||||
text_box.set_plain_text("Plotting live data")
|
||||
wf.clear_all()
|
||||
wf.title = "Scan: Live scan"
|
||||
wf.x_label = motor_name
|
||||
wf.y_label = signal_name
|
||||
wf.plot(device_x=motor_name, device_y=signal_name)
|
||||
|
||||
|
||||
def plot_fitted_data_bec(
|
||||
data,
|
||||
fit_result,
|
||||
):
|
||||
"""
|
||||
Plot fitted data and display fitting parameters in the specified window.
|
||||
|
||||
This function selects a BEC window and plots the original data along with the
|
||||
fitted function. Additionally, it displays the fitting results in a text
|
||||
box within the same window for better visualization of the fit results.
|
||||
|
||||
Parameters:
|
||||
data : dict
|
||||
Dictionary containing the original dataset, where 'x_data' and 'y_to_fit'
|
||||
hold the independent variable and the dependent variable, respectively,
|
||||
'scan_number' represents the scan number, 'motor_name' and 'signal_name'
|
||||
provide axis labels.
|
||||
fit_result : dict
|
||||
Dictionary containing the results of the fit, including parameters such
|
||||
as 'centre', 'fwhm', 'height', and the fitted model stored under
|
||||
'lmfit_result', with its 'best_fit' attribute representing the fitted data.
|
||||
"""
|
||||
wf, text_box = select_bec_window()
|
||||
fit_text = (
|
||||
f"Fit parameters: Centre = {fit_result['centre']:.4f}, "
|
||||
f"FWHM = {fit_result['fwhm']:.3f}, "
|
||||
f"Height = {fit_result['height']:.4f}\n"
|
||||
f"Model = {fit_result['model']}\n"
|
||||
f"Chi sq = {fit_result['chi_sq']:.3g}"
|
||||
)
|
||||
text_box.set_plain_text(fit_text)
|
||||
wf.clear_all()
|
||||
wf.title = f"Scan: {data['scan_number']}"
|
||||
wf.x_label = data["motor_name"]
|
||||
wf.y_label = data["signal_name"]
|
||||
wf.plot(x=data["x_data"], y=data["y_to_fit"], label="Data")
|
||||
wf.plot(x=fit_result["fit_xdata"], y=fit_result["fit_ydata"], label="Fit")
|
||||
# wf.Fit.set(symbol_size = 0)
|
||||
wf.get_curve('Fit').set(symbol_size=0)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Use the methods in mx_basics to perform:
|
||||
1) a go_to_peak scan, that scans a motor, finds the peak position and moves to peak
|
||||
2) fits data from a bec history file
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
|
||||
# from pxiii_parameters import FitDefaults, BPMScans, MirrorConfig
|
||||
|
||||
# from mx_basics import (
|
||||
# create_fit_parameters,
|
||||
# get_data_from_history,
|
||||
# fit,
|
||||
# plot_fitted_data_bec,
|
||||
# plot_live_data_bec,
|
||||
# )
|
||||
|
||||
|
||||
# Method functions
|
||||
def calculate_step_size(start: float, stop: float, steps: int) -> float:
|
||||
"""
|
||||
Provides the function to calculate the step size for dividing a specified range
|
||||
into a given number of steps.
|
||||
|
||||
Args:
|
||||
start: The starting value of the range.
|
||||
stop: The stopping value of the range.
|
||||
steps: The number of steps to divide the range into. Must be at least 1.
|
||||
|
||||
Raises:
|
||||
ValueError: If the steps value is less than 1.
|
||||
|
||||
Returns:
|
||||
The calculated step size as a float, rounded to three decimal places.
|
||||
"""
|
||||
if steps < 1:
|
||||
raise ValueError("Number of steps must be at least 1.")
|
||||
return round((stop - start) / steps, 3)
|
||||
|
||||
|
||||
def move_to_position(motor_device, motor_name: str, position: float, data: dict):
|
||||
"""
|
||||
Function to move a specified motor device to a given position.
|
||||
|
||||
The function verifies if the requested position is within the scan range of the
|
||||
motor device provided. If the position is outside the range, the motor is
|
||||
moved to the center of its scan range, an error message is raised, and the
|
||||
operation is halted. If the position is valid, the motor is moved to the
|
||||
specified position.
|
||||
|
||||
Parameters:
|
||||
motor_device: The motor device to be moved.
|
||||
motor_name: str
|
||||
The name of the motor as a string
|
||||
position: float
|
||||
The desired position to move the motor to. Position should be within
|
||||
the scan range of the motor determined by the provided data.
|
||||
data: dict
|
||||
A dictionary containing "x_data", which is used to determine the
|
||||
scan range of the motor.
|
||||
|
||||
Raises:
|
||||
ValueError: Raised if the specified position is outside the valid scan
|
||||
range determined by "x_data" in the data dictionary. The motor will
|
||||
return to the center of its scan range in this case.
|
||||
"""
|
||||
|
||||
motor_min = np.min(data["x_data"])
|
||||
motor_max = np.max(data["x_data"])
|
||||
motor_centre = (motor_max + motor_min) / 2
|
||||
|
||||
if not motor_min <= position <= motor_max:
|
||||
scans.umv(motor_device, motor_centre, relative=False)
|
||||
msg = (
|
||||
f"Position {position: .2f} is outside the scan range of "
|
||||
f"{motor_min: .2f} to {motor_max: .2f}. "
|
||||
f"Returning to centre of scan range {motor_centre: .3f}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
motor_position = round(position, 4)
|
||||
scans.umv(motor_device, motor_position, relative=False)
|
||||
print(f"\n Moving {motor_name} to position {motor_position: .3f}")
|
||||
|
||||
|
||||
# @dataclass(frozen=True)
|
||||
# class FitDefaults:
|
||||
# """Default values for fitting routines"""
|
||||
|
||||
# # Constants for default models, baselines, and parameters
|
||||
# MODEL = "Voigt"
|
||||
# BASELINE = "Linear"
|
||||
# SETTLE_TIME = 0.1
|
||||
# RELATIVE_MODE = True
|
||||
|
||||
|
||||
def go_to_peak(
|
||||
motor_device,
|
||||
signal_device,
|
||||
start: float,
|
||||
stop: float,
|
||||
steps: int,
|
||||
relative: bool = FitDefaults.RELATIVE_MODE,
|
||||
plot: bool = True,
|
||||
settle: float = FitDefaults.SETTLE_TIME,
|
||||
confirm: bool = True,
|
||||
gomax: bool = False,
|
||||
):
|
||||
"""
|
||||
Go to the peak of a signal by scanning a motor within a specified range and
|
||||
identifying the optimal position based on signal peak data.
|
||||
|
||||
Parameters:
|
||||
motor_device: The motor device to be scanned.
|
||||
signal_device: The signal device to monitor during the scan.
|
||||
start (float): The starting position of the scan. Ignored if `relative` is True.
|
||||
stop (float): The ending position of the scan. Ignored if `relative` is True.
|
||||
steps (int): The number of steps to divide the scan range into.
|
||||
relative (bool, optional): If True, interpret `start` and `stop` as relative to
|
||||
the current motor position. Defaults to RELATIVE_MODE constant.
|
||||
plot (bool, optional): If True, plot the scan data and the fitted results.
|
||||
Defaults to True.
|
||||
settle (float, optional): The time in seconds to wait after each step for the
|
||||
signal to stabilize. Defaults to DEFAULT_SETTLE_TIME constant.
|
||||
confirm (bool, optional): If True, ask for user confirmation before starting
|
||||
the scan. Defaults to True.
|
||||
|
||||
Raises:
|
||||
Exception: Raises exceptions potentially raised by dependent functions or
|
||||
operations such as plotting, fitting, or motor movement.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
motor_name = motor_device.name
|
||||
signal_name = signal_device.name
|
||||
# wf.plot(x_name=motor_name, y_name=signal_name)
|
||||
if plot:
|
||||
plot_live_data_bec(motor_name, signal_name)
|
||||
|
||||
# Validate and calculate step size
|
||||
step_size = calculate_step_size(start, stop, steps)
|
||||
|
||||
# Confirm the scan range
|
||||
# current_motor_position = motor_device.user_readback.get()
|
||||
current_motor_position = motor_device.read()[motor_name]["value"]
|
||||
if confirm:
|
||||
if relative:
|
||||
scan_start = current_motor_position + start
|
||||
scan_end = current_motor_position + stop
|
||||
print(
|
||||
f"\nScanning from {scan_start: .6g} to {scan_end: .6g} in "
|
||||
f"{steps} steps of size {step_size}"
|
||||
)
|
||||
print(f"Relative mode = {relative}")
|
||||
else:
|
||||
print(
|
||||
f"\nScanning from {start: .5g} to {stop: .5g} in {steps} steps of size {step_size}"
|
||||
)
|
||||
print(f"Relative mode = {relative}")
|
||||
input("Press Enter to continue...")
|
||||
# Perform the scan
|
||||
scan_result = scans.line_scan(
|
||||
motor_device, start, stop, steps=steps, relative=relative, settling_time=settle
|
||||
)
|
||||
motor_data = scan_result.scan.live_data[motor_name][motor_name].val
|
||||
signal_data = scan_result.scan.live_data[signal_name][signal_name].val
|
||||
scan_number = "Current"
|
||||
|
||||
data = {
|
||||
"x_data": np.array(motor_data),
|
||||
"y_data": np.array(signal_data),
|
||||
"motor_name": motor_name,
|
||||
"signal_name": signal_name,
|
||||
"motor_device": motor_device,
|
||||
"scan_number": scan_number,
|
||||
}
|
||||
|
||||
# Define and fit model to scan data
|
||||
fit_params = create_fit_parameters(False, FitDefaults.MODEL, FitDefaults.BASELINE)
|
||||
fit_result = fit(data, fit_params)
|
||||
|
||||
# Plot the fitted data if plot = True
|
||||
if plot:
|
||||
plot_fitted_data_bec(data, fit_result)
|
||||
|
||||
# If gomax is set then move to the maximum value, rather than the fit centre
|
||||
if gomax:
|
||||
value = fit_result["x_max"]
|
||||
print(f"Max position is at {value}")
|
||||
move_to_position(data["motor_device"], data["motor_name"], fit_result["x_max"], data)
|
||||
else:
|
||||
# Safely move the motor to the peak position
|
||||
move_to_position(data["motor_device"], data["motor_name"], fit_result["centre"], data)
|
||||
|
||||
|
||||
def fit_history(
|
||||
history_index: int,
|
||||
signal_name: str,
|
||||
deriv: bool = False,
|
||||
model: str = FitDefaults.MODEL,
|
||||
move_to_peak: bool = False,
|
||||
):
|
||||
"""
|
||||
Retrieve and analyze historical data by fitting a model, optionally moving to
|
||||
a peak position.
|
||||
|
||||
Parameters:
|
||||
history_index (int): Index of the historical data set to retrieve.
|
||||
signal_name (str): Name of the signal to fit.
|
||||
deriv (bool, optional): Whether to include the derivative in the fitting
|
||||
procedure. Defaults to False.
|
||||
model (str, optional): Name of the model to use for fitting. Defaults to
|
||||
DEFAULT_MODEL.
|
||||
move_to_peak (bool, optional): Whether to move the motor to the peak position
|
||||
after fitting. Defaults to False.
|
||||
|
||||
Raises:
|
||||
KeyError: If required keys are not found in the retrieved data dictionary.
|
||||
ValueError: If the fitting process fails or produces invalid results.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
# Retrieve historical data
|
||||
data = get_data_from_history(history_index, signal_name)
|
||||
|
||||
# Define fitting parameters
|
||||
fit_params = create_fit_parameters(deriv, model, FitDefaults.BASELINE)
|
||||
|
||||
# Perform fit and plot the data
|
||||
fit_result = fit(data, fit_params)
|
||||
plot_fitted_data_bec(data, fit_result)
|
||||
|
||||
# Optionally move the motor to the peak position
|
||||
if move_to_peak:
|
||||
move_to_position(data["motor_device"], data["motor_name"], fit_result["centre"], data)
|
||||
|
||||
|
||||
def scan_bpm(bpmname):
|
||||
"""
|
||||
Runs a grid scan of a BPM in x and y, and plots each channel
|
||||
as a heatmap.
|
||||
|
||||
Parameters:
|
||||
bpmname: the name of the bpm to be scanned e.g. "fe"
|
||||
|
||||
"""
|
||||
|
||||
# Open a dock area and set up the heatmaps
|
||||
dock_area = bec.gui.new("XBPM_Scan")
|
||||
wf5 = dock_area.new("Sum").new(bec.gui.available_widgets.Heatmap)
|
||||
wf1 = dock_area.new("Ch1", relative_to="Sum", position="bottom").new(
|
||||
bec.gui.available_widgets.Heatmap
|
||||
)
|
||||
wf3 = dock_area.new("Ch3", relative_to="Ch1", position="right").new(
|
||||
bec.gui.available_widgets.Heatmap
|
||||
)
|
||||
wf4 = dock_area.new("Ch4", relative_to="Ch3", position="bottom").new(
|
||||
bec.gui.available_widgets.Heatmap
|
||||
)
|
||||
wf2 = dock_area.new("Ch2", relative_to="Ch1", position="bottom").new(
|
||||
bec.gui.available_widgets.Heatmap
|
||||
)
|
||||
wfscan = dock_area.new("ScanControl").new(bec.gui.available_widgets.ScanControl)
|
||||
|
||||
cfg = getattr(BPMScans, bpmname)
|
||||
|
||||
wf1.x_label = cfg["x_name"]
|
||||
wf1.y_label = cfg["y_name"]
|
||||
wf1.plot(x_name=cfg["x_name"], y_name=cfg["y_name"], z_name=cfg["z1_name"], color_map="plasma")
|
||||
|
||||
wf2.x_label = cfg["x_name"]
|
||||
wf2.y_label = cfg["y_name"]
|
||||
wf2.plot(x_name=cfg["x_name"], y_name=cfg["y_name"], z_name=cfg["z2_name"], color_map="plasma")
|
||||
|
||||
wf3.x_label = cfg["x_name"]
|
||||
wf3.y_label = cfg["y_name"]
|
||||
wf3.plot(x_name=cfg["x_name"], y_name=cfg["y_name"], z_name=cfg["z3_name"], color_map="plasma")
|
||||
|
||||
wf4.x_label = cfg["x_name"]
|
||||
wf4.y_label = cfg["y_name"]
|
||||
wf4.plot(x_name=cfg["x_name"], y_name=cfg["y_name"], z_name=cfg["z4_name"], color_map="plasma")
|
||||
|
||||
wf5.x_label = cfg["x_name"]
|
||||
wf5.y_label = cfg["y_name"]
|
||||
wf5.plot(x_name=cfg["x_name"], y_name=cfg["y_name"], z_name=cfg["z5_name"], color_map="plasma")
|
||||
# Run the scan
|
||||
x_mot = cfg["x_device"]
|
||||
y_mot = cfg["y_device"]
|
||||
# scans.grid_scan(x_mot, -0.5, 0.5, 20, y_mot, -0.5, 0.5, 20,
|
||||
# exp_time=0.5, relative=False, snaked=True)
|
||||
|
||||
|
||||
def optimise_kb(mirror):
|
||||
"""
|
||||
Runs a grid scan of a the upstream and downstream benders,
|
||||
and plots a heatmap of the sample camera x or y sigma.
|
||||
|
||||
Parameters:
|
||||
mirror: either "hfm" or :vfm"
|
||||
|
||||
"""
|
||||
|
||||
# Open a dock area and set up the heatmaps
|
||||
dock_area = bec.gui.new(mirror)
|
||||
wf1 = dock_area.new("Heatmap").new(bec.gui.available_widgets.Heatmap)
|
||||
|
||||
wfscan = dock_area.new("ScanControl").new(bec.gui.available_widgets.ScanControl)
|
||||
|
||||
cfg = getattr(MirrorConfig, mirror)
|
||||
|
||||
wf1.x_label = cfg["bu_name"]
|
||||
wf1.y_label = cfg["bd_name"]
|
||||
wf1.plot(x_name=cfg["bu_name"], y_name=cfg["bd_name"], z_name=cfg["z_name"], color_map="plasma")
|
||||
|
||||
# Run the scan
|
||||
x_mot = cfg["x_device"]
|
||||
y_mot = cfg["y_device"]
|
||||
# scans.grid_scan(x_mot, -0.02, 0.02, 11, y_mot, -0.02, 0.02, 11,
|
||||
# exp_time=0.5, relative=True, snaked=True)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Define guard policies for devices in the beamline."""
|
||||
|
||||
# from guards import GuardViolation, guards_setup
|
||||
|
||||
|
||||
def is_sample_area_clear_for_beamstop(d):
|
||||
"""Check if the sample area is clear of diag_y, coll_y, and gonx"""
|
||||
g = guards_setup(d)
|
||||
if g["diag_y_out"].check() and g["coll_y_out"].check() and g["gonx_out"].check():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_sample_area_clear_for_gonx(d):
|
||||
"""Check if the sample area is clear of diag_y and bs_z"""
|
||||
g = guards_setup(d)
|
||||
if g["diag_y_out"].check() and g["bs_work_min"].check():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def make_aerotech_x_policy(d):
|
||||
"""Create the policy for aerotech_x"""
|
||||
|
||||
def aerotech_x_policy(target):
|
||||
cfg = d["aerotech_x"].positions
|
||||
if target >= cfg["out"] and not is_sample_area_clear_for_gonx(d):
|
||||
raise GuardViolation("Sample area is not clear")
|
||||
|
||||
return aerotech_x_policy
|
||||
|
||||
|
||||
def make_bs_z_policy(d):
|
||||
"""Create the policy for bs_z"""
|
||||
|
||||
def bs_z_policy(target):
|
||||
"""Checks that the target position is within limits"""
|
||||
cfg = d["bs_z"].positions
|
||||
# Lower bound
|
||||
if target < cfg["work_min"] and not is_sample_area_clear_for_beamstop(d):
|
||||
raise GuardViolation("Sample area is not clear")
|
||||
if target < cfg["min"]:
|
||||
raise GuardViolation(
|
||||
f"Requested beamstop Z {target} is below recommended minimum {cfg['min']}"
|
||||
)
|
||||
# Upper bound
|
||||
if d["bl_pos"].pos == "in" and target > cfg["max_blin"]:
|
||||
raise GuardViolation(
|
||||
f"Beamstop Z cannot move beyond {cfg['max_blin']} when backlight is IN"
|
||||
)
|
||||
|
||||
return bs_z_policy
|
||||
|
||||
|
||||
def make_diag_y_policy(d):
|
||||
"""Create the policy for diag_y"""
|
||||
|
||||
def diag_y_policy(target):
|
||||
cfg = d["diag_y"].positions
|
||||
# Don't move in if the goniometer is in
|
||||
if d["aerotech_x"].actual >= d['aerotech_x'].positions['in'] and target > cfg["out"]:
|
||||
raise GuardViolation(
|
||||
f"Diagnostic device cannot move beyond {cfg['out']} mm when goniometer is not OUT"
|
||||
)
|
||||
# Don't move if cryocooler is in
|
||||
if d['cryo_pos'].pos == 'in' and target > cfg['out']:
|
||||
raise GuardViolation(
|
||||
f"Diagnostic device cannot move beyond {cfg['out']} mm when cryocooler is IN"
|
||||
)
|
||||
return diag_y_policy
|
||||
|
||||
|
||||
def attach_policies(d):
|
||||
"""Attach the policies to the devices"""
|
||||
d["bs_z"].policy = make_bs_z_policy(d)
|
||||
d["aerotech_x"].policy = make_aerotech_x_policy(d)
|
||||
d["diag_y"].policy = make_diag_y_policy(d)
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Set up the positioned devices for mock or real motors"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, List, Dict, Optional, Union
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class SimpleStatus:
|
||||
"""Makes a mock motor return a status"""
|
||||
|
||||
def __init__(self, motor, target, delay=0.0, success=True, name=""):
|
||||
self.motor = motor
|
||||
self.target = target
|
||||
self.delay = delay
|
||||
self._success = success
|
||||
self.name = name
|
||||
self._done = False
|
||||
|
||||
def wait(self, timeout=None):
|
||||
start = time.time()
|
||||
|
||||
while True:
|
||||
# simulate motion completion
|
||||
if not self._done:
|
||||
if time.time() - start >= self.delay:
|
||||
if self._success:
|
||||
self.motor.position = self.target
|
||||
self._done = True
|
||||
|
||||
if self._done:
|
||||
if not self._success:
|
||||
raise RuntimeError(f"Motor {self.name} failed")
|
||||
return True
|
||||
|
||||
if timeout is not None and (time.time() - start) > timeout:
|
||||
raise TimeoutError(f"Timeout waiting for {self.name}")
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
class MotorAdapter:
|
||||
"""Motor adapter for setting up mock/real motors"""
|
||||
|
||||
def move(self, pos: float):
|
||||
"""Move the motor to the given position"""
|
||||
raise NotImplementedError
|
||||
|
||||
def move_with_status(self, pos: float):
|
||||
"""Move the motor to the given position with a status"""
|
||||
raise NotImplementedError
|
||||
|
||||
def set_fail(self, value: bool):
|
||||
"""Put the motor into a failure state"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def actual(self) -> float:
|
||||
"""The actual position of the motor"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MockMotorAdapter(MotorAdapter):
|
||||
"""Motor adapter for mock motors"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self._motor = PositionDevice.MockMotor(name)
|
||||
|
||||
def move(self, pos: float):
|
||||
"""Move the motor to the given position"""
|
||||
self._motor.move(pos)
|
||||
|
||||
def move_with_status(self, pos: float):
|
||||
"""Move the motor to the given position with a status"""
|
||||
if self._motor.fail:
|
||||
return SimpleStatus(
|
||||
motor=self._motor,
|
||||
target=pos,
|
||||
delay=self._motor.delay,
|
||||
success=False,
|
||||
name=self._motor.name,
|
||||
)
|
||||
|
||||
# don't update position immediately
|
||||
return SimpleStatus(
|
||||
motor=self._motor,
|
||||
target=pos,
|
||||
delay=self._motor.delay,
|
||||
success=True,
|
||||
name=self._motor.name,
|
||||
)
|
||||
|
||||
def set_fail(self, value: bool):
|
||||
"""Put the motor into a failure state"""
|
||||
self._motor.fail = value
|
||||
|
||||
def set_delay(self, value: float):
|
||||
self._motor.delay = value
|
||||
|
||||
@property
|
||||
def actual(self) -> float:
|
||||
"""The actual position of the motor"""
|
||||
return self._motor.position
|
||||
|
||||
|
||||
class RealMotorAdapter(MotorAdapter):
|
||||
"""Motor adapter for real motors"""
|
||||
|
||||
def __init__(self, mot, name):
|
||||
self._motor = mot
|
||||
self._name = name
|
||||
|
||||
def move(self, pos: float):
|
||||
"""Move the motor to the given position"""
|
||||
scans.umv(self._motor, pos, relative=False)
|
||||
|
||||
def move_with_status(self, pos: float):
|
||||
"""Move the motor to the given position with a status"""
|
||||
return scans.mv(self._motor, pos, relative=False)
|
||||
|
||||
def set_fail(self, value: bool):
|
||||
"""Put the motor into a failure state"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def actual(self) -> float:
|
||||
"""The actual position of the motor"""
|
||||
return self._motor.read()[self._name]["value"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PositionDevice:
|
||||
"""Generic device that moves between named or numeric positions"""
|
||||
|
||||
bec_name: str
|
||||
mot_device: any = None
|
||||
positions: Dict[str, float] = field(default_factory=dict)
|
||||
tol: float = 0.1
|
||||
# device_timeout = 3
|
||||
guards: List[Callable[[], None]] = field(default_factory=list)
|
||||
policy: Optional[Callable[[float], None]] = None
|
||||
allow_arbitrary: bool = False
|
||||
use_mock: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
if self.use_mock:
|
||||
self.mot = MockMotorAdapter(self.bec_name)
|
||||
else:
|
||||
# real_motor = getattr(dev, self.bec_name)
|
||||
self.mot = RealMotorAdapter(self.mot_device, self.bec_name)
|
||||
# self.mot = RealMotorAdapter(real_motor, self.bec_name)
|
||||
# self.mot = getattr(dev, self.bec_name)
|
||||
|
||||
# Normalize position names
|
||||
self.positions = {k.lower(): v for k, v in self.positions.items()}
|
||||
|
||||
def _check_guards(self):
|
||||
"""Check if guards exist"""
|
||||
for g in self.guards:
|
||||
g()
|
||||
|
||||
def _resolve_target(self, target: Union[str, float]) -> float:
|
||||
"""Convert target into a motor position"""
|
||||
|
||||
if isinstance(target, str):
|
||||
name = target.lower()
|
||||
|
||||
if name not in self.positions:
|
||||
raise ValueError(f"Unknown position '{target}'")
|
||||
|
||||
return self.positions[name]
|
||||
|
||||
if isinstance(target, (float, int)):
|
||||
if not self.allow_arbitrary:
|
||||
raise ValueError(f"{self.bec_name} only accepts named positions")
|
||||
return float(target)
|
||||
|
||||
raise TypeError("Target must be str or float")
|
||||
|
||||
def move(self, target: Union[str, float]):
|
||||
"""Move devices"""
|
||||
pos = self._resolve_target(target)
|
||||
|
||||
self._check_guards()
|
||||
|
||||
if self.policy:
|
||||
self.policy(pos)
|
||||
|
||||
self.mot.move(pos)
|
||||
|
||||
def mv(self, target: Union[str, float]):
|
||||
"""move devices with a timeout"""
|
||||
|
||||
pos = self._resolve_target(target)
|
||||
|
||||
self._check_guards()
|
||||
|
||||
if self.policy:
|
||||
self.policy(pos)
|
||||
|
||||
# status.wait(self.device_timeout)
|
||||
return self.mot.move_with_status(pos)
|
||||
|
||||
def set_position(self, target: Union[str, float]):
|
||||
"""Only to be used for testing purposes, bypasses guards"""
|
||||
pos = self._resolve_target(target)
|
||||
self.mot.move(pos)
|
||||
|
||||
@property
|
||||
def actual(self) -> float:
|
||||
"""Return the actual position of the device."""
|
||||
# return self.mot.read()[self.bec_name]["value"]
|
||||
return self.mot.actual
|
||||
|
||||
@property
|
||||
def pos(self) -> str:
|
||||
"""Return the closest matching position"""
|
||||
|
||||
for name, pos in self.positions.items():
|
||||
if abs(self.actual - pos) <= self.tol:
|
||||
return name
|
||||
|
||||
return "unknown"
|
||||
|
||||
#
|
||||
|
||||
def is_at(self, target: Union[str, float]) -> bool:
|
||||
"""Return True if the device is at the given position."""
|
||||
|
||||
pos = self._resolve_target(target)
|
||||
return abs(self.actual - pos) <= self.tol
|
||||
|
||||
# -------------------------
|
||||
# Mock Motor Implementation
|
||||
# -------------------------
|
||||
|
||||
class MockMotor:
|
||||
"""Mock motor implementation"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.position = 0.0
|
||||
self._target = self.position
|
||||
self.fail = False
|
||||
self.delay = 0
|
||||
|
||||
#
|
||||
def move(self, pos: float):
|
||||
"""Move the motor to the given position."""
|
||||
if self.fail:
|
||||
return
|
||||
# raise RuntimeError(f"Motor {self.name} failed")
|
||||
time.sleep(self.delay)
|
||||
self.position = pos
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Script to change energy at PXIII by setting DCCM motors and mirror stripe
|
||||
|
||||
Moving DCCM motors - implemented for dccm_theta1 and dccm_theta2
|
||||
Mirrors - change of mirror stripe is not yet implemented
|
||||
Plotting optional
|
||||
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
# from mx_methods import go_to_peak
|
||||
# from pxiii_parameters import EnergyDefaults
|
||||
|
||||
# from calculator import (
|
||||
# validate_energy,
|
||||
# convert_from_bragg,
|
||||
# convert_from_energy,
|
||||
# )
|
||||
|
||||
|
||||
def get_current_energy():
|
||||
"""
|
||||
Returns the energy in eV from the current bragg angle.
|
||||
"""
|
||||
# current_bragg_angle = dev.dccm_theta1.user_readback.get()
|
||||
current_bragg_angle = -EnergyDefaults.energy.user_readback.get()
|
||||
current_energy = convert_from_bragg(current_bragg_angle, print_result=False)[
|
||||
"energy_ev"
|
||||
]
|
||||
return current_energy
|
||||
|
||||
|
||||
# Functions below are common to all beamlines
|
||||
def calculate_energy_difference(current_energy, target_energy):
|
||||
"""
|
||||
Calculate the absolute difference in energy between the current energy level
|
||||
and the target energy level.
|
||||
"""
|
||||
return abs(target_energy - current_energy)
|
||||
|
||||
|
||||
def interpolate_column(energy_ev, x_values, y_values):
|
||||
"""
|
||||
Perform interpolation for a specific column of data.
|
||||
|
||||
This function uses numpy's interpolation method to perform linear
|
||||
interpolation. It calculates the interpolated y-values corresponding
|
||||
to the given energy in electron-volts (energy_ev), based on specified
|
||||
x-values and y-values.
|
||||
|
||||
Parameters:
|
||||
energy_ev (array-like): Array of energy values in electron-volts
|
||||
at which interpolation is needed.
|
||||
x_values (array-like): Array of x-values corresponding to known
|
||||
data points.
|
||||
y_values (array-like): Array of y-values corresponding to known
|
||||
data points.
|
||||
|
||||
Returns:
|
||||
numpy.ndarray: Interpolated y-values computed for the energy_ev
|
||||
input.
|
||||
"""
|
||||
return np.interp(energy_ev, x_values, y_values)
|
||||
|
||||
|
||||
def get_value_from_lut(energy_ev):
|
||||
"""
|
||||
Retrieve interpolated values from a Lookup Table (LUT) based on the provided energy
|
||||
in electron volts (eV).
|
||||
|
||||
This function reads a CSV file containing energy lookup data and processes the LUT
|
||||
to return interpolated values for specified relevant columns. The interpolation is
|
||||
performed for the provided energy value using the LUT's energy and data values.
|
||||
|
||||
Args:
|
||||
energy_ev (float): The energy value in electron volts for which the interpolated data is
|
||||
required.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary where the keys are the relevant column names from the LUT, and the values
|
||||
are the interpolated values as floats.
|
||||
"""
|
||||
|
||||
energy_lookup_data = pd.read_csv(EnergyDefaults.LUT_table)
|
||||
column_names = energy_lookup_data.columns.tolist()
|
||||
lut_values = energy_lookup_data.values.astype(float).T
|
||||
|
||||
# Filter relevant columns for interpolation
|
||||
relevant_columns = {"dccm_pitch"}
|
||||
int_values = {}
|
||||
|
||||
for i, col_name in enumerate(column_names):
|
||||
if col_name in relevant_columns:
|
||||
int_values[col_name] = float(
|
||||
interpolate_column(energy_ev, lut_values[0], lut_values[i])
|
||||
)
|
||||
return int_values
|
||||
|
||||
|
||||
def get_mirror_stripe(energy_ev):
|
||||
"""
|
||||
Determines the mirror stripe material based on the energy level provided.
|
||||
|
||||
This function evaluates the given energy level in electron volts (eV) and
|
||||
identifies the material type that corresponds to the specified thresholds
|
||||
for silicon, rhodium, and, if applicable, platinum.
|
||||
|
||||
Args:
|
||||
energy_ev (float): Energy level in electron volts, used to determine
|
||||
the corresponding material type.
|
||||
|
||||
Returns:
|
||||
str: A string indicating the material type corresponding to the provided
|
||||
energy level. Possible values are "silicon", "rhodium", or "platinum".
|
||||
"""
|
||||
if energy_ev <= EnergyDefaults.stripe_thresholds["silicon"]:
|
||||
return "silicon"
|
||||
if (
|
||||
EnergyDefaults.stripe_thresholds["silicon"]
|
||||
< energy_ev
|
||||
<= EnergyDefaults.stripe_thresholds["rhodium"]
|
||||
):
|
||||
return "rhodium"
|
||||
return "platinum"
|
||||
|
||||
|
||||
def set_mirror_stripe(energy_ev):
|
||||
"""
|
||||
Selects and sets the appropriate mirror stripe based on the given energy value
|
||||
in electron volts (eV). Prints the selected mirror stripe.
|
||||
|
||||
Args:
|
||||
energy_ev (float): The energy value in electron volts used to determine
|
||||
the appropriate mirror stripe.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
selected_stripe = get_mirror_stripe(energy_ev)
|
||||
print(f"Selected mirror stripe: {selected_stripe}")
|
||||
|
||||
|
||||
def mono_pitch_scan(plot=True):
|
||||
"""Scan the monochromator pitch and move to the peak."""
|
||||
|
||||
if plot:
|
||||
print("Scanning monochromator pitch and moving to peak, with plotting.")
|
||||
go_to_peak(
|
||||
EnergyDefaults.mono_pitch,
|
||||
EnergyDefaults.signals["sig1"],
|
||||
-EnergyDefaults.pitch_scan["halfwidth"],
|
||||
EnergyDefaults.pitch_scan["halfwidth"],
|
||||
steps=EnergyDefaults.pitch_scan["steps"],
|
||||
relative=True,
|
||||
settle=0.01,
|
||||
plot=True,
|
||||
confirm=False,
|
||||
)
|
||||
else:
|
||||
print("Scanning monochromator pitch and moving to peak, without plotting.")
|
||||
go_to_peak(
|
||||
EnergyDefaults.mono_pitch,
|
||||
EnergyDefaults.signals["sig1"],
|
||||
-EnergyDefaults.pitch_scan["halfwidth"],
|
||||
EnergyDefaults.pitch_scan["halfwidth"],
|
||||
steps=EnergyDefaults.pitch_scan["steps"],
|
||||
relative=True,
|
||||
settle=0.01,
|
||||
plot=False,
|
||||
confirm=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def get_dccm_motors_positions(energy_ev):
|
||||
"""
|
||||
Retrieve the positions of DCCM motors based on given energy value.
|
||||
The function returns a dictionary containing all
|
||||
calculated motor positions.
|
||||
|
||||
Arguments:
|
||||
energy_ev (float): The energy value in electron volts for which the
|
||||
DCCM motor positions are to be calculated.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the calculated DCCM motor positions
|
||||
including values retrieved from the lookup table, if applicable.
|
||||
"""
|
||||
# dccm_motor_values = get_value_from_lut(energy_ev)
|
||||
th1_angle = -convert_from_energy(energy_ev, print_result=False)["bragg_angle_deg"]
|
||||
th2_angle = convert_from_energy(energy_ev, temp=298, print_result=False)["bragg_angle_deg"]
|
||||
# dccm_motor_values.update({"theta1_angle": th1_angle, "theta2_angle": th2_angle})
|
||||
dccm_motor_values = {"theta1_angle": th1_angle, "theta2_angle": th2_angle}
|
||||
return dccm_motor_values
|
||||
|
||||
|
||||
def move_dccm_motors(energy_ev):
|
||||
"""
|
||||
Move the DCCM theta1 and theta2 motors to the required positions
|
||||
for the given energy in eV.
|
||||
|
||||
"""
|
||||
dccm_pos = get_dccm_motors_positions(energy_ev)
|
||||
print(
|
||||
f"Moving DCCM theta1: {dccm_pos['theta1_angle']: .5g} deg, theta2: {dccm_pos['theta2_angle']: .5g} deg, "
|
||||
# f"DCM pitch: {dcm_pos['dcm_pitch']: .5g} mrad, "
|
||||
)
|
||||
umv(EnergyDefaults.energy, dccm_pos["theta1_angle"],
|
||||
EnergyDefaults.mono_pitch, dccm_pos["theta2_angle"])
|
||||
|
||||
|
||||
|
||||
def bl_energy(energy_ev, plot=True):
|
||||
"""
|
||||
Adjusts the beamline's energy to the specified energy in electron volts (eV).
|
||||
The function validates the target energy, checks the current energy, and makes
|
||||
adjustments only if the energy difference is significant enough. It performs
|
||||
necessary operations including changing DCCM motors, updating
|
||||
the mirror stripe, and scanning to find the optimal DCCM pitch of 2nd crystal..
|
||||
|
||||
Args:
|
||||
energy_ev: Target energy in electron volts to which the beamline should be adjusted.
|
||||
plot: Boolean flag indicating whether to plot the DCCM pitch scan for finding the peak.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
energy_ev = validate_energy(energy_ev) # Ensure energy is valid.
|
||||
|
||||
# Check current energy to avoid unnecessary adjustments.
|
||||
|
||||
current_energy = get_current_energy()
|
||||
energy_diff = calculate_energy_difference(current_energy, energy_ev)
|
||||
|
||||
if energy_diff <= EnergyDefaults.min_energy_change:
|
||||
print(
|
||||
f"Energy change of {energy_diff:.2f} eV is too small, not changing energy."
|
||||
)
|
||||
return
|
||||
|
||||
# Step 1: Move and set the DCCM motors.
|
||||
move_dccm_motors(energy_ev)
|
||||
|
||||
# Step 2: Update the mirror stripe.
|
||||
set_mirror_stripe(energy_ev)
|
||||
|
||||
# Step 3: Perform DCCM pitch scan and move to peak.
|
||||
if plot:
|
||||
mono_pitch_scan(plot=True)
|
||||
else:
|
||||
mono_pitch_scan(plot=False)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""File to store beamline parameters and defaults"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FitDefaults:
|
||||
"""Default values for fitting routines"""
|
||||
|
||||
# Constants for default models, baselines, and parameters
|
||||
MODEL = "Voigt"
|
||||
BASELINE = "Linear"
|
||||
SETTLE_TIME = 0.1
|
||||
RELATIVE_MODE = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnergyDefaults:
|
||||
"""Parameters for PXIII energy changes"""
|
||||
|
||||
min_energy_change = 1
|
||||
min_energy_ev = 4500
|
||||
max_energy_ev = 15000
|
||||
signals = {
|
||||
"sig1": dev.dccm_di_top,
|
||||
"sig2": dev.dccm_bpmsum,
|
||||
"sig3": dev.ss_bpmsum,
|
||||
# "sig4": dev.xbox_xbpm,
|
||||
}
|
||||
energy = dev.dccm_theta1
|
||||
mono_pitch = dev.dccm_theta2
|
||||
# LUT_table = "luts/energy_lut.csv"
|
||||
stripe_thresholds = {"silicon": 9000, "rhodium": 40000}
|
||||
pitch_scan = {"halfwidth": 0.15, "steps": 30}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CamConversion:
|
||||
"""Convert pixels to microns for sam cam"""
|
||||
|
||||
a = 0.5208
|
||||
b = 0.002586
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BPMScans:
|
||||
"""Define the names of the motors and bpm channels"""
|
||||
|
||||
ss = {
|
||||
"x_name": dev.ss_bpm_x.name,
|
||||
"y_name": dev.ss_bpm_y.name,
|
||||
"z1_name": dev.ss_bpm1.name,
|
||||
"z2_name": dev.ss_bpm2.name,
|
||||
"z3_name": dev.ss_bpm3.name,
|
||||
"z4_name": dev.ss_bpm3.name,
|
||||
"z5_name": dev.ss_bpmsum.name,
|
||||
"x_device": dev.ss_bpm_x,
|
||||
"y_device": dev.ss_bpm_y,
|
||||
}
|
||||
|
||||
|
||||
# @dataclass(frozen=True)
|
||||
# class MirrorConfig:
|
||||
# """Define the names of the mirror channels"""
|
||||
|
||||
# hfm = {
|
||||
# "bu_name": dev.hfm_bu.name,
|
||||
# "bd_name": dev.hfm_bd.name,
|
||||
# "z_name": dev.samcam_xsig.name,
|
||||
# "x_device": dev.hfm_bu,
|
||||
# "y_device": dev.hfm_bd,
|
||||
# }
|
||||
# vfm = {
|
||||
# "bu_name": dev.vfm_bu.name,
|
||||
# "bd_name": dev.vfm_bd.name,
|
||||
# "z_name": dev.samcam_ysig.name,
|
||||
# "x_device": dev.vfm_bu,
|
||||
# "y_device": dev.vfm_bd,
|
||||
# }
|
||||
@@ -0,0 +1,8 @@
|
||||
from .mx_measurements import (
|
||||
MeasureStandardWedge,
|
||||
MeasureVerticalLine,
|
||||
MeasureRasterSimple,
|
||||
MeasureScreening,
|
||||
MeasureHelical,
|
||||
MeasureHelical2,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
# from .metadata_schema_template import ExampleSchema
|
||||
|
||||
METADATA_SCHEMA_REGISTRY = {
|
||||
# Add models which should be used to validate scan metadata here.
|
||||
# Make a model according to the template, and import it as above
|
||||
# Then associate it with a scan like so:
|
||||
# "example_scan": ExampleSchema
|
||||
}
|
||||
|
||||
# Define a default schema type which should be used as the fallback for everything:
|
||||
|
||||
DEFAULT_SCHEMA = None
|
||||
@@ -0,0 +1,34 @@
|
||||
# # By inheriting from BasicScanMetadata you can define a schema by which metadata
|
||||
# # supplied to a scan must be validated.
|
||||
# # This schema is a Pydantic model: https://docs.pydantic.dev/latest/concepts/models/
|
||||
# # but by default it will still allow you to add any arbitrary information to it.
|
||||
# # That is to say, when you run a scan with which such a model has been associated in the
|
||||
# # metadata_schema_registry, you can supply any python dictionary with strings as keys
|
||||
# # and built-in python types (strings, integers, floats) as values, and these will be
|
||||
# # added to the experiment metadata, but it *must* contain the keys and values of the
|
||||
# # types defined in the schema class.
|
||||
# #
|
||||
# #
|
||||
# # For example, say that you would like to enforce recording information about sample
|
||||
# # pretreatment, you could define the following:
|
||||
# #
|
||||
#
|
||||
# from bec_lib.metadata_schema import BasicScanMetadata
|
||||
#
|
||||
#
|
||||
# class ExampleSchema(BasicScanMetadata):
|
||||
# treatment_description: str
|
||||
# treatment_temperature_k: int
|
||||
#
|
||||
#
|
||||
# # If this was used according to the example in metadata_schema_registry.py,
|
||||
# # then when calling the scan, the user would need to write something like:
|
||||
# >>> scans.example_scan(
|
||||
# >>> motor,
|
||||
# >>> 1,
|
||||
# >>> 2,
|
||||
# >>> 3,
|
||||
# >>> metadata={"treatment_description": "oven overnight", "treatment_temperature_k": 575},
|
||||
# >>> )
|
||||
#
|
||||
# # And the additional metadata would be saved in the HDF5 file created for the scan.
|
||||
@@ -0,0 +1,473 @@
|
||||
"""MX measurements module
|
||||
|
||||
Scan primitives for standard BEC scans at the PX beamlines at SLS.
|
||||
Theese scans define the event model and can be called from higher levels.
|
||||
"""
|
||||
|
||||
import time
|
||||
import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
from bec_server.scan_server.scans import AsyncFlyScanBase
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class AbrCmd:
|
||||
"""Valid Aerotech ABR scan commands from the AeroBasic files"""
|
||||
|
||||
NONE = 0
|
||||
RASTER_SCAN_SIMPLE = 1
|
||||
MEASURE_STANDARD = 2
|
||||
VERTICAL_LINE_SCAN = 3
|
||||
SCREENING = 4
|
||||
# SUPER_FAST_OMEGA = 5 # Some Japanese measured samples in capilaries at high RPMs
|
||||
# STILL_WEDGE = 6 # NOTE: Unused Step scan
|
||||
# STILLS = 7 # NOTE: Unused Just send triggers to detector
|
||||
# REPEAT_SINGLE_OSCILLATION = 8 # NOTE: Unused
|
||||
# SINGLE_OSCILLATION = 9
|
||||
# OLD_FASHIONED = 10 # NOTE: Unused
|
||||
# RASTER_SCAN = 11
|
||||
# JET_ROTATION = 12 # NOTE: Unused
|
||||
# X_HELICAL = 13 # NOTE: Unused
|
||||
# X_RUNSEQ = 14 # NOTE: Unused
|
||||
# JUNGFRAU = 15
|
||||
# MSOX = 16 # NOTE: Unused
|
||||
# SLIT_SCAN = 17 # NOTE: Unused
|
||||
# RASTER_SCAN_STILL = 18
|
||||
# SCAN_SASTT = 19
|
||||
# SCAN_SASTT_V2 = 20
|
||||
# SCAN_SASTT_V3 = 21
|
||||
|
||||
|
||||
class AerotechFlyscanBase(AsyncFlyScanBase):
|
||||
"""Base class for MX flyscans
|
||||
|
||||
Low-level base class for standard scans at the PX beamlines at SLS. Theese
|
||||
scans use the A3200 rotation stage and the actual experiment is performed
|
||||
using an AeroBasic script controlled via global variables. The base class
|
||||
has some basic safety features like checking status then sets globals and
|
||||
fires off the scan. Implementations can choose to set the corresponding
|
||||
configurations in child classes or pass it as command line parameters.
|
||||
|
||||
IMPORTANT: The AeroBasic scripts take care of the PSO configuration.
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
abr_complete : bool
|
||||
Wait for the launched ABR task to complete.
|
||||
"""
|
||||
|
||||
scan_type = "fly"
|
||||
scan_report_hint = "table"
|
||||
arg_input = {}
|
||||
arg_bundle_size = {"bundle": len(arg_input), "min": None, "max": None}
|
||||
|
||||
# Aerotech stage config
|
||||
abr_raster_reset = False
|
||||
abr_complete = False
|
||||
abr_timeout = None
|
||||
pointID = 0
|
||||
num_pos = 0
|
||||
|
||||
def __init__(self, *args, parameter: dict = None, **kwargs):
|
||||
"""Just set num_pos=0 to avoid hanging and override defaults if explicitly set from
|
||||
parameters.
|
||||
"""
|
||||
super().__init__(parameter=parameter, **kwargs)
|
||||
if "abr_raster_reset" in self.caller_kwargs:
|
||||
self.abr_raster_reset = self.caller_kwargs.get("abr_raster_reset")
|
||||
if "abr_complete" in self.caller_kwargs:
|
||||
self.abr_complete = self.caller_kwargs.get("abr_complete")
|
||||
if "abr_timeout" in self.caller_kwargs:
|
||||
self.abr_timeout = self.caller_kwargs.get("abr_timeout")
|
||||
|
||||
def pre_scan(self):
|
||||
"""Mostly just checking if ABR stage is ok..."""
|
||||
# TODO: Move roughly to start position???
|
||||
|
||||
# ABR status checking
|
||||
stat = yield from self.stubs.send_rpc_and_wait("abr", "status.get")
|
||||
if stat not in (0, "OK"):
|
||||
raise RuntimeError("Aerotech ABR seems to be in error state {stat}, please reset")
|
||||
task = yield from self.stubs.send_rpc_and_wait("abr", "task1.get")
|
||||
# From what I got values are copied to local vars at the start of scan,
|
||||
# so only kickoff should be forbidden.
|
||||
if task not in (1, "OK"):
|
||||
raise RuntimeError("Aerotech ABR task #1 seems to busy")
|
||||
|
||||
# Reset the raster scan engine
|
||||
if self.abr_raster_reset:
|
||||
yield from self.stubs.send_rpc_and_wait("abr", "raster_scan_done.set", 0)
|
||||
|
||||
# Call super
|
||||
yield from super().pre_scan()
|
||||
|
||||
def scan_core(self):
|
||||
"""The actual scan logic comes here."""
|
||||
# Kick off the run
|
||||
yield from self.stubs.send_rpc_and_wait("abr", "bluekickoff")
|
||||
logger.info("Measurement launched on the ABR stage...")
|
||||
|
||||
# Wait for grid scanner to finish
|
||||
if self.abr_complete:
|
||||
if self.abr_timeout is not None:
|
||||
st = yield from self.stubs.send_rpc_and_wait("abr", "complete", self.abr_timeout)
|
||||
st.wait()
|
||||
else:
|
||||
st = yield from self.stubs.send_rpc_and_wait("abr", "complete")
|
||||
st.wait()
|
||||
|
||||
def cleanup(self):
|
||||
"""Set scan progress to 1 to finish the scan"""
|
||||
self.num_pos = 1
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
class MeasureStandardWedge(AerotechFlyscanBase):
|
||||
"""Standard wedge scan using the OMEGA motor
|
||||
|
||||
Measure an absolute continous line scan from `start` to `start` + `range`
|
||||
during `move_time` on the Omega axis with PSO output.
|
||||
|
||||
The scan itself is executed by the scan service running on the Aerotech
|
||||
controller. Ophyd just configures, launches it and waits for completion.
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> scans.standard_wedge(start=42, range=10, move_time=20)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
start : (float, float)
|
||||
Scan start position of the axis.
|
||||
range : (float, float)
|
||||
Scan range of the axis.
|
||||
move_time : (float)
|
||||
Total travel time for the movement [s].
|
||||
ready_rate : float, optional
|
||||
No clue what is this... (default=500)
|
||||
"""
|
||||
|
||||
scan_name = "standardscan"
|
||||
required_kwargs = ["start", "range", "move_time"]
|
||||
|
||||
|
||||
class MeasureVerticalLine(AerotechFlyscanBase):
|
||||
"""Vertical line scan using the GMY motor
|
||||
|
||||
Simple relative continous line scan that records a single vertical line
|
||||
with PSO output. There's no actual stepping, it's only used for velocity
|
||||
calculation.
|
||||
|
||||
The scan itself is executed by the scan service running on the Aerotech
|
||||
controller. Ophyd just configures, launches it and waits for completion.
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> scans.measure_vline(range_y=12, steps_y=40, exp_time=0.1)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
range : float
|
||||
Step size [mm].
|
||||
steps : int
|
||||
Scan range of the axis.
|
||||
exp_time : float
|
||||
Eeffective exposure time per step [s]
|
||||
"""
|
||||
|
||||
scan_name = "vlinescan"
|
||||
required_kwargs = ["exp_time", "range", "steps"]
|
||||
|
||||
|
||||
class MeasureRasterSimple(AerotechFlyscanBase):
|
||||
"""Simple raster scan
|
||||
|
||||
Measure a simplified relative zigzag raster scan in the X-Y plane.
|
||||
The scan is relative assumes the goniometer is currently at the CENTER of
|
||||
the first cell (i.e. TOP-LEFT). Each line is executed as a single continous
|
||||
movement, i.e. there's no actual stepping in the X direction.
|
||||
|
||||
The scan itself is executed by the scan service running on the Aerotech
|
||||
controller. Ophyd just configures, launches it and waits for completion.
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> scans.raster_simple(exp_time=0.1, range_x=4, range_y=4, steps_x=80, steps_y=80)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
exp_time : float
|
||||
Effective exposure time for each cell along the X axis [s].
|
||||
range_x : float
|
||||
Scan step size [mm].
|
||||
range_y : float
|
||||
Scan step size [mm].
|
||||
steps_x : int
|
||||
Number of scan steps in X (fast). Only used for velocity calculation.
|
||||
steps_y : int
|
||||
Number of scan steps in Y (slow).
|
||||
"""
|
||||
|
||||
scan_name = "rasterscan"
|
||||
required_kwargs = ["exp_time", "range_x", "range_y", "steps_x", "steps_y"]
|
||||
|
||||
|
||||
class MeasureScreening(AerotechFlyscanBase):
|
||||
"""Sample screening scan
|
||||
|
||||
Sample screening scan that scans intervals on the rotation axis taking
|
||||
1 image/interval. This makes sure that we hit diffraction peaks if there
|
||||
are any crystals.
|
||||
|
||||
The scan itself is executed by the scan service running on the Aerotech
|
||||
controller. Ophyd just configures, launches it and waits for completion.
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> scans.measure_screening(start=42, range=180, steps=18, exp_time=0.1, oscrange=2.0)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
start : float
|
||||
Absolute scan start position of the omega axis [deg].
|
||||
range : float
|
||||
Total screened range of the omega axis relative to 'start' [deg].
|
||||
steps : int
|
||||
Number of blurred intervals.
|
||||
exp_time : float
|
||||
Exposure time per blurred interval [s].
|
||||
oscrange : float
|
||||
Motion blurring of each interval [deg]
|
||||
delta : float
|
||||
Safety margin for sub-range movements (default: 0.5) [deg].
|
||||
"""
|
||||
|
||||
scan_name = "screeningscan"
|
||||
required_kwargs = ["start", "range", "steps", "exp_time", "oscrange"]
|
||||
|
||||
|
||||
class MeasureHelical(AerotechFlyscanBase):
|
||||
"""Helical scan using the OMEGA motor
|
||||
|
||||
Measure an absolute continous line scan from `start` to `start` + `range`
|
||||
during `move_time` on the Omega axis with PSO output.
|
||||
|
||||
The scan itself is executed by the scan service running on the Aerotech
|
||||
controller. Ophyd just configures, launches it and waits for completion.
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> scans.standard_wedge(start=42, range=10, move_time=20)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
start : float
|
||||
Scan start position of the axis.
|
||||
range : float
|
||||
Scan range of the axis.
|
||||
move_time : float
|
||||
Total travel time for the movement [s].
|
||||
ready_rate : float, optional
|
||||
No clue what is this... (default=500)
|
||||
sg_start : (float, float, float, float, float)
|
||||
Complete SmarGon coordinate in tuple form.
|
||||
sg_end : (float, float, float, float, float)
|
||||
Complete SmarGon coordinate in tuple form.
|
||||
sg_steps : int
|
||||
Number of steps with SmarGon.
|
||||
"""
|
||||
|
||||
scan_name = "helicalscan"
|
||||
required_kwargs = ["start", "range", "move_time", "sg_start", "sg_end", "sg_steps"]
|
||||
|
||||
def pre_scan(self):
|
||||
"""Mostly just checking if ABR stage is ok..."""
|
||||
|
||||
# Smargon has no velocity control
|
||||
self.smargon_start = np.array(self.caller_kwargs.get("sg_start"))
|
||||
self.smargon_end = np.array(self.caller_kwargs.get("sg_end"))
|
||||
self.smargon_steps = self.caller_kwargs.get("sg_steps")
|
||||
self.smargon_range = self.smargon_end - self.smargon_start
|
||||
self.smargon_step_size = self.smargon_range / self.smargon_steps
|
||||
self.smargon_step_time = self.caller_kwargs.get("move_time") / self.smargon_steps
|
||||
|
||||
logger.info(f"Start:\t{self.smargon_start}")
|
||||
logger.info(f"End:\t{self.smargon_end}")
|
||||
logger.info(f"Steps:\t{self.smargon_steps}")
|
||||
logger.info(f"Range:\t{self.smargon_range}")
|
||||
logger.info(f"StepSize:\t{self.smargon_step_size}")
|
||||
logger.info(f"StepTime:\t{self.smargon_step_time}")
|
||||
|
||||
# TODO: Move roughly to start position???
|
||||
st0 = yield from self.stubs.send_rpc("shx", "omove", self.smargon_start[0])
|
||||
st1 = yield from self.stubs.send_rpc("shy", "omove", self.smargon_start[1])
|
||||
st2 = yield from self.stubs.send_rpc("shz", "omove", self.smargon_start[2])
|
||||
st3 = yield from self.stubs.send_rpc("chi", "omove", self.smargon_start[3])
|
||||
st4 = yield from self.stubs.send_rpc("phi", "omove", self.smargon_start[4])
|
||||
st0.wait()
|
||||
st1.wait()
|
||||
st2.wait()
|
||||
st3.wait()
|
||||
st4.wait()
|
||||
|
||||
# Call super
|
||||
yield from super().pre_scan()
|
||||
|
||||
def scan_core(self):
|
||||
"""The actual scan logic comes here."""
|
||||
# Kick off the run
|
||||
yield from self.stubs.send_rpc_and_wait("abr", "kickoff")
|
||||
logger.info("Measurement launched on the ABR stage...")
|
||||
|
||||
logger.info("Performing SmarGon stepping...")
|
||||
for ss in range(self.smargon_steps):
|
||||
sg_pos = self.smargon_start + ss * self.smargon_step_size
|
||||
# Move to position but don't care
|
||||
st0 = yield from self.stubs.send_rpc("shx", "omove", sg_pos[0])
|
||||
st1 = yield from self.stubs.send_rpc("shy", "omove", sg_pos[1])
|
||||
st2 = yield from self.stubs.send_rpc("shz", "omove", sg_pos[2])
|
||||
st3 = yield from self.stubs.send_rpc("chi", "omove", sg_pos[3])
|
||||
st4 = yield from self.stubs.send_rpc("phi", "omove", sg_pos[4])
|
||||
t_start = time.time()
|
||||
st0.wait()
|
||||
st1.wait()
|
||||
st2.wait()
|
||||
st3.wait()
|
||||
st4.wait()
|
||||
t_end = time.time()
|
||||
t_elapsed = t_end - t_start
|
||||
time.sleep(max(self.smargon_step_time - t_elapsed, 0))
|
||||
|
||||
# Wait for scan task to finish
|
||||
if self.abr_complete:
|
||||
if self.abr_timeout is not None:
|
||||
st = yield from self.stubs.send_rpc_and_wait("abr", "complete", self.abr_timeout)
|
||||
st.wait()
|
||||
else:
|
||||
st = yield from self.stubs.send_rpc_and_wait("abr", "complete")
|
||||
st.wait()
|
||||
|
||||
|
||||
class MeasureHelical2(AerotechFlyscanBase):
|
||||
"""Helical scan using the OMEGA motor
|
||||
|
||||
Measure an absolute continous line scan from `start` to `start` + `range`
|
||||
during `move_time` on the Omega axis with PSO output.
|
||||
|
||||
The scan itself is executed by the scan service running on the Aerotech
|
||||
controller. Ophyd just configures, launches it and waits for completion.
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> scans.standard_wedge(start=42, range=10, move_time=20)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
start : float
|
||||
Scan start position of the axis.
|
||||
range : float
|
||||
Scan range of the axis.
|
||||
move_time : float
|
||||
Total travel time for the movement [s].
|
||||
ready_rate : float, optional
|
||||
No clue what is this... (default=500)
|
||||
sg_start : (float, float, float, float, float)
|
||||
Complete SmarGon coordinate in tuple form.
|
||||
sg_end : (float, float, float, float, float)
|
||||
Complete SmarGon coordinate in tuple form.
|
||||
sg_steps : int
|
||||
Number of steps with SmarGon.
|
||||
"""
|
||||
|
||||
scan_name = "helicalscan2"
|
||||
required_kwargs = ["start", "range", "move_time", "sg_start", "sg_end", "sg_steps"]
|
||||
point_id = 0
|
||||
|
||||
# def __init__(self, *args, parameter: dict = None, **kwargs):
|
||||
# """Just set num_pos=0 to avoid hanging and override defaults if explicitly set from
|
||||
# parameters.
|
||||
# """
|
||||
# self.num_pos = kwargs["sg_steps"]
|
||||
# super().__init__(*args, parameter=parameter, **kwargs)
|
||||
|
||||
def prepare_positions(self):
|
||||
# Smargon has no velocity control
|
||||
self.smargon_start = np.array(self.caller_kwargs.get("sg_start"))
|
||||
self.smargon_end = np.array(self.caller_kwargs.get("sg_end"))
|
||||
self.smargon_steps = self.caller_kwargs.get("sg_steps")
|
||||
self.smargon_range = self.smargon_end - self.smargon_start
|
||||
self.smargon_step_size = self.smargon_range / self.smargon_steps
|
||||
self.smargon_step_time = self.caller_kwargs.get("move_time") / self.smargon_steps
|
||||
|
||||
logger.info(f"Start:\t{self.smargon_start}")
|
||||
logger.info(f"End:\t{self.smargon_end}")
|
||||
logger.info(f"Steps:\t{self.smargon_steps}")
|
||||
logger.info(f"Range:\t{self.smargon_range}")
|
||||
logger.info(f"StepSize:\t{self.smargon_step_size}")
|
||||
logger.info(f"StepTime:\t{self.smargon_step_time}")
|
||||
|
||||
self.num_pos = self.smargon_steps
|
||||
self.positions = np.linspace(self.smargon_start, self.smargon_end, self.smargon_steps)
|
||||
self.start_pos = self.positions[0, :]
|
||||
# Call super
|
||||
yield from super().prepare_positions()
|
||||
|
||||
# def update_scan_motors(self):
|
||||
# """ Update step scan motors"""
|
||||
# self.scan_motors = ['shx', 'shy', 'shz', 'chi', 'phi']
|
||||
|
||||
def pre_scan(self):
|
||||
"""Mostly just checking if ABR stage is ok..."""
|
||||
# Move roughly to start position
|
||||
st0 = yield from self.stubs.send_rpc("shx", "omove", self.start_pos[0])
|
||||
st1 = yield from self.stubs.send_rpc("shy", "omove", self.start_pos[1])
|
||||
st2 = yield from self.stubs.send_rpc("shz", "omove", self.start_pos[2])
|
||||
st3 = yield from self.stubs.send_rpc("chi", "omove", self.start_pos[3])
|
||||
st4 = yield from self.stubs.send_rpc("phi", "omove", self.start_pos[4])
|
||||
st0.wait()
|
||||
st1.wait()
|
||||
st2.wait()
|
||||
st3.wait()
|
||||
st4.wait()
|
||||
|
||||
# print(f"\n\n{self.readout_priority}\n\n")
|
||||
|
||||
# Call super
|
||||
yield from super().pre_scan()
|
||||
|
||||
def scan_core(self):
|
||||
"""The actual scan logic comes here."""
|
||||
# Kick off the run
|
||||
yield from self.stubs.send_rpc_and_wait("abr", "kickoff")
|
||||
logger.info("Measurement launched on the ABR stage...")
|
||||
|
||||
logger.info("Performing SmarGon stepping...")
|
||||
for _, sg_pos in enumerate(self.positions):
|
||||
# sg_pos = self.smargon_start + ss * self.smargon_step_size
|
||||
# Move to position but don't care
|
||||
st0 = yield from self.stubs.send_rpc("shx", "omove", sg_pos[0])
|
||||
st1 = yield from self.stubs.send_rpc("shy", "omove", sg_pos[1])
|
||||
st2 = yield from self.stubs.send_rpc("shz", "omove", sg_pos[2])
|
||||
st3 = yield from self.stubs.send_rpc("chi", "omove", sg_pos[3])
|
||||
st4 = yield from self.stubs.send_rpc("phi", "omove", sg_pos[4])
|
||||
t_start = time.time()
|
||||
st0.wait()
|
||||
st1.wait()
|
||||
st2.wait()
|
||||
st3.wait()
|
||||
st4.wait()
|
||||
t_end = time.time()
|
||||
t_elapsed = t_end - t_start
|
||||
time.sleep(max(self.smargon_step_time - t_elapsed, 0))
|
||||
yield from self.stubs.read(group="monitored", point_id=self.point_id)
|
||||
self.point_id += 1
|
||||
|
||||
# Wait for scan task to finish
|
||||
if self.abr_complete:
|
||||
if self.abr_timeout is not None:
|
||||
st = yield from self.stubs.send_rpc_and_wait("abr", "complete", self.abr_timeout)
|
||||
st.wait()
|
||||
else:
|
||||
st = yield from self.stubs.send_rpc_and_wait("abr", "complete")
|
||||
st.wait()
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Scan components for pxiii_bec.
|
||||
|
||||
The scan components module allows you to define custom components that can be used in your scans.
|
||||
These components can be used to encapsulate reusable logic, interact with devices, or perform specific actions during the scan lifecycle.
|
||||
"""
|
||||
|
||||
from bec_server.scan_server.scans.scan_components import ScanComponents
|
||||
|
||||
|
||||
class PxiiiBecScanComponents(ScanComponents):
|
||||
"""Scan components for pxiii_bec."""
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Scan modifier plugin for pxiii_bec.
|
||||
|
||||
The scan modifier allows you to modify the scan lifecycle and run custom actions before or after the scan hook or replace the scan hook entirely.
|
||||
Note that the scan_modifier module must be registered as a plugin in the pyproject.toml file for it to be recognized by the BEC framework and that
|
||||
there can only be one scan_modifier plugin registered at a time. If you need to run multiple scan modifiers, you can create a single scan
|
||||
modifier plugin that runs multiple actions in sequence with conditional logic to determine which actions to run based on the scan context.
|
||||
"""
|
||||
|
||||
from bec_server.scan_server.scans.scan_modifier import ScanModifier, scan_hook_impl
|
||||
|
||||
|
||||
class PxiiiBecScanModifier(ScanModifier):
|
||||
"""
|
||||
Scan modifier for pxiii_bec.
|
||||
|
||||
By inheriting from the ScanModifier base class, you get access to currently running scan (self.scan), the devices (self.dev), the scan info (self.scan_info),
|
||||
the scan components (self.components) and the scan actions (self.actions).
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize the scan modifier."""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Example of running code before the scan stage for a specific scan
|
||||
# @scan_hook_impl("stage", "before")
|
||||
# def before_stage(self):
|
||||
# """Run before the stage hook."""
|
||||
# self.actions.send_client_info("Custom stage logic executed by ScanModifier.")
|
||||
# if self.scan_info.scan_name == "example_scan":
|
||||
# self.dev.samx.set(20)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import numpy as np
|
||||
from scipy.ndimage import gaussian_filter1d
|
||||
from lmfit.models import GaussianModel
|
||||
|
||||
|
||||
def alignment_fit_and_plot(
|
||||
history_index: int,
|
||||
device_name: str,
|
||||
signal_name: str | None = None,
|
||||
smoothing_sigma: float = 2.0,
|
||||
):
|
||||
"""
|
||||
Get data for a completed scan from the BEC history, apply smoothing, gaussian fit,
|
||||
gradient, and plot all the results.
|
||||
|
||||
Args:
|
||||
history_index (int): scan to fetch, e.g. -1 for the most recent scan
|
||||
device_name (str): the device for which to get the monitoring data
|
||||
signal_name (str | None): the signal to plot, if different from the device name.
|
||||
smoothing_sigma (float): the sigma for the Gaussian smoothing filter
|
||||
"""
|
||||
# Fetch scan data from the history
|
||||
# by default, signal = device name, unless otherwise specified
|
||||
signal = signal_name or device_name
|
||||
scan = bec.history[history_index]
|
||||
md = scan.metadata["bec"]
|
||||
data = scan.devices[device_name][signal].read()["value"]
|
||||
# motor name is a bytes object in the metadata, so make a string
|
||||
motor_name = md["scan_motors"][0].decode()
|
||||
|
||||
# Create a plot and a text box to display results
|
||||
dock_area = bec.gui.new()
|
||||
wf = dock_area.new(bec.gui.available_widgets.Waveform)
|
||||
wf.title = f"Scan {md['scan_number']}: {md['scan_name']} of {motor_name}"
|
||||
text = dock_area.new(bec.gui.available_widgets.TextBox, where="right")
|
||||
|
||||
# Calculate some processed data and add everything to the plot
|
||||
wf.plot(data, label="Raw data")
|
||||
smoothed_data = gaussian_filter1d(data, smoothing_sigma)
|
||||
wf.plot(smoothed_data, label="Smoothed")
|
||||
gradient = np.gradient(smoothed_data)
|
||||
wf.plot(gradient, label="gradient")
|
||||
|
||||
# Fit a Gaussian model to the smoothed data and show the fitting parameters in the textbox
|
||||
x_data = scan.devices[motor_name][motor_name].read()["value"]
|
||||
model = GaussianModel()
|
||||
result = model.fit(smoothed_data, x=x_data)
|
||||
text.set_plain_text(f"Fit parameters: \n{result.params.pretty_repr()}")
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,85 @@
|
||||
# pylint: disable=undefined-variable
|
||||
# import bec
|
||||
# import bec_lib.devicemanager.DeviceContainer as dev
|
||||
|
||||
import time
|
||||
|
||||
|
||||
def _device_name(device):
|
||||
return device.name if hasattr(device, "name") else str(device)
|
||||
|
||||
|
||||
def _get_or_create_scan_window(name="CurrentScan"):
|
||||
window = bec.gui.windows.get(name)
|
||||
if window is None:
|
||||
return bec.gui.new(name)
|
||||
window.delete_all()
|
||||
return window
|
||||
|
||||
|
||||
def rock(steps, exp_time, scan_start=None, scan_end=None, datasource=None, visual=True, **kwargs):
|
||||
"""Demo step scan with plotting
|
||||
|
||||
This is a simple user-space demo step scan with the BEC. It be a
|
||||
standard BEC scan, while still setting up the environment.
|
||||
|
||||
Example:
|
||||
--------
|
||||
ascan(dev.dccm_energy, 12,13, steps=21, exp_time=0.1, datasource=dev.dccm_xbpm)
|
||||
"""
|
||||
# Dummy method to check beamline status
|
||||
if not bl_check_beam():
|
||||
raise RuntimeError("Beamline is not in ready state")
|
||||
|
||||
motor = dev.dccm_theta2
|
||||
if scan_start is None:
|
||||
scan_start = -0.05 / dev.dccm_energy.user_readback.get()
|
||||
if scan_end is None:
|
||||
scan_end = 0.05 / dev.dccm_energy.user_readback.get()
|
||||
|
||||
if visual:
|
||||
# Get or create scan specific window.
|
||||
window = _get_or_create_scan_window("CurrentScan")
|
||||
motor_name = _device_name(motor)
|
||||
datasource_name = _device_name(datasource)
|
||||
|
||||
# Draw a waveform plot in the window.
|
||||
plt1 = window.new(
|
||||
bec.gui.available_widgets.Waveform, object_name=f"ScanDisplay_{motor_name}"
|
||||
)
|
||||
plt1.plot(device_x=motor_name, device_y=datasource_name, dap="LinearModel")
|
||||
plt1.x_label = motor_name
|
||||
plt1.y_label = datasource_name
|
||||
window.show()
|
||||
|
||||
print("Handing over to 'scans.line_scan'")
|
||||
s = scans.line_scan(
|
||||
motor,
|
||||
scan_start,
|
||||
scan_end,
|
||||
steps=steps,
|
||||
exp_time=exp_time,
|
||||
datasource=datasource,
|
||||
relative=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if visual:
|
||||
# If fitting via GUI
|
||||
firt_par = plt1.get_dap_params()
|
||||
else:
|
||||
# Without GUI
|
||||
firt_par = bec.dap.LinearModel.fit(
|
||||
s, motor.name, motor.name, datasource.name, datasource.name
|
||||
)
|
||||
|
||||
# TODO: Validate fitted position
|
||||
# TODO: Move to fitted maximum
|
||||
|
||||
return s, firt_par
|
||||
|
||||
|
||||
def monitor(device, steps, t=1):
|
||||
for _ in range(steps):
|
||||
print(device.read())
|
||||
time.sleep(t)
|
||||
@@ -0,0 +1,29 @@
|
||||
def scan_theta2(scan_start, scan_end, stepno, exp):
|
||||
# Save the motor starting position
|
||||
start_value = dev.dccm_theta2.read()['dccm_theta2']['value']
|
||||
print(f"Motor position is {start_value}")
|
||||
|
||||
# Run the scan
|
||||
s = scans.line_scan(dev.dccm_theta2, scan_start, scan_end, steps=stepno, exp_time=exp, relative=True)
|
||||
|
||||
# data = s.devices[dccm_xbpm][dccm_xbpm].read()["value"]
|
||||
|
||||
# Move motor back to starting position and print XBPM reading
|
||||
umv(dev.dccm_theta2, start_value)
|
||||
xbpm_reading = dev.dccm_xbpm.read()['dccm_xbpm']['value']
|
||||
print(f"Moving dccm_theta2 back to start position of where XBPM Reading is {xbpm_reading}")
|
||||
end_value = dev.dccm_theta2.read()['dccm_theta2']['value']
|
||||
print(f"Motor was at {start_value} before the scan, now at {end_value}")
|
||||
|
||||
# # Create a plot to display the results
|
||||
dock_area = bec.gui.new()
|
||||
wf = dock_area.new(bec.gui.available_widgets.Waveform)
|
||||
wf.title = f"Scan of DCCM_theta2"
|
||||
wf.plot(device_x="dccm_theta2", device_y="dccm_xbpm", label="dccm_xbpm-dccm_xbpm")
|
||||
dap_xbpm = wf.add_dap_curve(device_label="dccm_xbpm-dccm_xbpm", dap_name="GaussianModel")
|
||||
print(dap_xbpm.dap_params)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# pylint: disable=undefined-variable
|
||||
|
||||
|
||||
def bl_check_beam():
|
||||
"""Check beamline status before scan"""
|
||||
return True
|
||||
|
||||
|
||||
def _device_name(device):
|
||||
return device.name if hasattr(device, "name") else str(device)
|
||||
|
||||
|
||||
def _get_or_create_scan_window(name="CurrentScan"):
|
||||
window = bec.gui.windows.get(name)
|
||||
if window is None:
|
||||
return bec.gui.new(name)
|
||||
window.delete_all()
|
||||
return window
|
||||
|
||||
|
||||
def ascan(
|
||||
motor, scan_start, scan_end, steps, exp_time, plot=None, visual=True, relative=False, **kwargs
|
||||
):
|
||||
"""Demo step scan with plotting
|
||||
|
||||
This is a simple user-space demo step scan with the BEC. It be a
|
||||
standard BEC scan, while still setting up the environment.
|
||||
|
||||
Example:
|
||||
--------
|
||||
ascan(dev.dccm_energy, 12,13, steps=21, exp_time=0.1, datasource=dev.dccm_xbpm)
|
||||
"""
|
||||
# Dummy method to check beamline status
|
||||
if not bl_check_beam():
|
||||
raise RuntimeError("Beamline is not in ready state")
|
||||
|
||||
if visual:
|
||||
# Get or create scan specific window.
|
||||
window = _get_or_create_scan_window("CurrentScan")
|
||||
motor_name = _device_name(motor)
|
||||
plot_name = _device_name(plot)
|
||||
|
||||
# Draw a waveform plot in the window.
|
||||
plt1 = window.new(
|
||||
bec.gui.available_widgets.Waveform, object_name=f"ScanDisplay_{motor_name}"
|
||||
)
|
||||
plt1.plot(device_x=motor_name, device_y=plot_name, dap="LinearModel")
|
||||
plt1.x_label = motor_name
|
||||
plt1.y_label = plot_name
|
||||
window.show()
|
||||
|
||||
print("Handing over to 'scans.line_scan'")
|
||||
s = scans.line_scan(
|
||||
motor,
|
||||
scan_start,
|
||||
scan_end,
|
||||
steps=steps,
|
||||
exp_time=exp_time,
|
||||
plot=plot,
|
||||
relative=relative,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if visual:
|
||||
# Fitting via GUI
|
||||
firt_par = plt1.get_dap_params()
|
||||
else:
|
||||
# Fitting without GUI
|
||||
firt_par = bec.dap.LinearModel.fit(s, motor.name, motor.name, plot.name, plot.name)
|
||||
|
||||
# # Some basic fit
|
||||
# dkey = datasource.full_name
|
||||
# NOTE: s.scan.data == bec.history[-1]
|
||||
# datapoints = bec.history[-1].devices[dkey].read()[dkey]['value']
|
||||
# positions
|
||||
|
||||
return s, firt_par
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user