Compare commits

...

122 Commits

Author SHA1 Message Date
6647140d43 w
Some checks failed
CI for csaxs_bec / test (pull_request) Successful in 1m30s
CI for csaxs_bec / test (push) Has been cancelled
2026-01-23 15:25:52 +01:00
b48b27114d cleanup
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m27s
CI for csaxs_bec / test (push) Successful in 1m33s
2026-01-23 15:16:18 +01:00
11c887b078 feat(debug-tools): add debug tools and adjust logic from beamline tests
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m27s
CI for csaxs_bec / test (push) Successful in 1m30s
2026-01-23 15:00:35 +01:00
bfcecd73c2 refactor(mcs-ddg): cleanup and fix mcs and ddg from beamline tests 2026-01-23 13:29:43 +01:00
146b10eb85 tests: fix tests for ddg and mcs integrations 2026-01-23 13:29:43 +01:00
48ad1b334c docs: Add documentation to MCS and DDG modules 2026-01-23 13:29:43 +01:00
188e23df48 fix: Fix MCS card and DDG implementation after testing with hardware at cSAXS 2026-01-23 13:29:43 +01:00
14c56939bf fix(ddg): adapt DDG, remove mcs.readytoread 2026-01-23 13:29:43 +01:00
ef0c31c8dc refactor(mcs-card): adjust mcs card to only have mca channels. 2026-01-23 13:29:43 +01:00
2cf2f4b4e4 test(controller): Fix test for controller wait_for_connection
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m16s
CI for csaxs_bec / test (push) Successful in 1m26s
2026-01-16 10:52:24 +01:00
1a9a0beb86 fix(controller): Ensure wait_for_connection calls controller.on()
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m13s
CI for csaxs_bec / test (pull_request) Successful in 1m19s
2026-01-15 18:09:34 +01:00
9f9aef348a some more docs
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m16s
CI for csaxs_bec / test (push) Successful in 1m18s
2026-01-08 12:36:14 +01:00
x01dc
f7a313b37f added file selection in gui docs
Some checks failed
CI for csaxs_bec / test (push) Has been cancelled
2026-01-08 12:06:47 +01:00
7326c471f8 test(falcon): fix test for improved patched_device method in ophyd_devices
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m19s
CI for csaxs_bec / test (push) Successful in 1m18s
2026-01-07 11:17:50 +01:00
4b95ebace3 test(falcon): fix test after falcon refactoring 2026-01-07 11:17:50 +01:00
c1dee287b8 refactor(falcon): Migrate Falcon integration to PsiDeviceBase 2026-01-07 11:17:50 +01:00
dd3b0144b9 feat(pilatus): deprecate pilatus integration 2026-01-07 11:17:50 +01:00
149af32ab1 fix: rate limit warning log in live mode
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m30s
CI for csaxs_bec / test (push) Successful in 1m32s
2026-01-06 13:22:43 +01:00
x01dc
47f0b66791 mod gui tools for pdf viewer
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m29s
CI for csaxs_bec / test (push) Successful in 1m28s
2026-01-06 12:49:40 +01:00
2c0fced9b7 FZP layout 60 nm
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m32s
2026-01-06 12:00:56 +01:00
d99b44b619 refactor(configs):Migrate deviceClass for EpicsMotorEX to EpicsUserMotorVME
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m30s
CI for csaxs_bec / test (push) Successful in 1m30s
2025-12-11 07:52:52 +01:00
x12sa
dbab981ac2 add micfoc and change some motor settings 2025-12-11 07:52:52 +01:00
x12sa
bdd7f1767f new yaml file sastt for sastt setup 2025-12-11 07:52:52 +01:00
0f41648053 test(rt-flomni): fix tests for rt-flomni
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m25s
CI for csaxs_bec / test (push) Successful in 1m35s
2025-12-09 16:51:24 +01:00
ec45bb4c33 fix(controller): deprecate get_device_manager() in favor of dm 2025-12-09 16:34:53 +01:00
ac8177a132 fix(controller): add controller.on to wait_for_connection for devices with socket controllers 2025-12-09 16:34:53 +01:00
36e8d87411 refactor(controller): refactor set_device_enable method from controller to set_device_read_write 2025-12-09 16:34:53 +01:00
f56a834db5 fix(controller): fix controller init for all controller instances, fix formatting 2025-12-09 16:34:53 +01:00
90d2c99c4a fix: remove deprecated bl_check
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m15s
CI for csaxs_bec / test (push) Successful in 1m22s
2025-12-05 17:16:13 +01:00
6a4bfc73f6 fix(status): fix usage of Compare, Transition Status in delaygenerator integration
Some checks failed
CI for csaxs_bec / test (pull_request) Failing after 1m18s
CI for csaxs_bec / test (push) Successful in 1m23s
2025-11-30 22:27:52 +01:00
x01dc
22d8dbe972 added force monochrome mode plus delay after disconnect to ensure
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m45s
CI for csaxs_bec / test (push) Successful in 1m18s
working after config reload
2025-11-12 13:36:12 +01:00
x01dc
2411e7be56 new shutter device
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m17s
CI for csaxs_bec / test (push) Successful in 1m17s
2025-11-07 14:34:05 +01:00
f8b20752f5 updated gui section
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m22s
2025-11-05 13:12:59 +01:00
e301b94e7c fix: add num_rotation_90 and transpose to ids_camera
Some checks failed
CI for csaxs_bec / test (pull_request) Failing after 1m0s
CI for csaxs_bec / test (push) Successful in 1m25s
2025-11-04 14:12:04 +01:00
x01dc
61011f098d attribute based checking if window exists
Some checks failed
CI for csaxs_bec / test (pull_request) Failing after 42s
CI for csaxs_bec / test (push) Successful in 1m33s
2025-11-04 13:39:35 +01:00
x01dc
efca170f04 minor fixes: basepath correction file, raise gui
Some checks failed
CI for csaxs_bec / test (pull_request) Failing after 38s
CI for csaxs_bec / test (push) Successful in 1m21s
2025-11-03 11:23:32 +01:00
x01dc
af2a69f825 various fixes or adjustments during testing the new alignment gui
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m18s
CI for csaxs_bec / test (push) Successful in 1m18s
2025-10-31 11:38:27 +01:00
c8c71d466c refactor(xray_gui): minor cleanup
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m17s
CI for csaxs_bec / test (push) Successful in 1m14s
2025-10-23 14:39:59 +02:00
94d984b8a2 fix(camera): BGR to RGB logic moved to Camera 2025-10-23 14:39:59 +02:00
x01dc
39d2c97247 fix: xray eye align script changes 2025-10-23 14:39:59 +02:00
x01dc
de22611941 refactor(ids_camera): old ids_camera deleted, ids_camera_new is now ids_camera 2025-10-23 14:39:59 +02:00
4723f6768b feat(xray_eye): add XRayEye widget and plugin for GUI integration 2025-10-23 11:07:30 +02:00
x01dc
5b76c3f769 feat(gui_instruction_device): added gui instruction device from epics 2025-10-23 11:04:21 +02:00
4590b85010 fix(omny_alignment): disabled not working signal connection to on_move_up 2025-10-23 11:02:28 +02:00
7233fb8d35 w
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m16s
CI for csaxs_bec / test (push) Successful in 1m19s
2025-10-22 10:55:58 +02:00
32d3232008 fix(ids-camera): fix roi_signal after AsyncSignal refactoring
Some checks failed
CI for csaxs_bec / test (pull_request) Failing after 3s
CI for csaxs_bec / test (push) Failing after 23s
2025-10-22 10:48:40 +02:00
x01dc
725eed17ed finalized flomni temp and humidity display
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m16s
CI for csaxs_bec / test (push) Successful in 1m15s
2025-10-14 11:41:31 +02:00
x01dc
1d9fb39c0e initial version flomni temp humidity 2025-10-14 11:41:31 +02:00
x01dc
f1dd299fad gui tools running 2025-10-14 11:41:31 +02:00
1fcb213336 ci: pull from github instead of gitea
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m23s
CI for csaxs_bec / test (push) Successful in 1m22s
2025-10-08 16:33:34 +02:00
x01dc
b08c7bf44b removed debug info
Some checks failed
CI for csaxs_bec / test (push) Failing after 34s
CI for csaxs_bec / test (pull_request) Failing after 35s
2025-10-08 16:23:38 +02:00
x01dc
d16f6b703c tested gui tools 2025-10-08 16:23:38 +02:00
x01dc
f526d5cc05 gui tools running 2025-10-08 16:23:38 +02:00
gac-x01dc
8f7914b978 - added camera viewer device
- fixed some issues in flomni sample
storage device
2025-10-08 16:22:13 +02:00
gac-x01dc
6c65d5546c fixed issue with flomni storage not updating and start using OMNYTools for yesno 2025-10-08 16:22:13 +02:00
2bb6667f30 test(eiger): cleanup and add tests
Some checks failed
CI for csaxs_bec / test (push) Failing after 34s
2025-09-17 17:40:43 +02:00
e3f337e7c3 fix: deprecate old eiger9m_csaxs integration 2025-09-17 17:40:43 +02:00
419d15dcdb fix(mcs-card): fix mcs card test for on_connected 2025-09-17 17:40:43 +02:00
84537eafde refactor(eiger): cleanup, remove auto initialization 2025-09-17 17:40:43 +02:00
6a864c9bc6 refactor(jungfrau-joch-client): Improve wait_till_idle method 2025-09-17 17:40:43 +02:00
afbf7adff5 refactor(eiger): add file event signal 2025-09-17 17:40:43 +02:00
dd304f2f3b feat(eiger): add jfj integration 2025-09-17 17:40:43 +02:00
216d8c200b fix: assign session to session_name instead of username
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m32s
CI for csaxs_bec / test (push) Successful in 1m30s
2025-09-12 17:01:11 +02:00
x12sa
01a9d607a6 add slit1 center and size to device config file
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m30s
CI for csaxs_bec / test (push) Successful in 1m32s
2025-09-12 15:50:36 +02:00
9e48dca5e2 refactor: copier upgrade to v1-2-2
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m31s
CI for csaxs_bec / test (push) Successful in 1m31s
2025-09-12 15:44:09 +02:00
Holler
dc3d51afc4 fixed issue with move_to_start 2025-09-12 15:43:38 +02:00
Holler
2a579c957d fixes 2025-09-12 15:43:38 +02:00
Holler
66cd89ed63 flomni fixes 2025-09-12 15:43:38 +02:00
d06fe65491 feat: update repository with copier template for migration to gitea 2025-09-11 15:23:35 +02:00
189141a047 refactor(ids-camera): add additional information to the docstrings 2025-08-06 15:30:10 +02:00
f69e643092 refactor(mcs): imrove documentation for counter updates 2025-08-06 14:05:18 +02:00
5c263bdb63 refactor(ids-camera): improve docs and camera class 2025-08-06 14:05:18 +02:00
9f5254abe2 refactor(ddg): cleanup and improve comments 2025-08-06 13:43:54 +02:00
a58c23845f refactor(ddg1): remove threadpool, use dedicated thread for polling 2025-08-06 10:50:39 +02:00
c7e11eeabc refactor(ddg): use threadpool with active polling for state 2025-08-05 16:11:56 +02:00
a03e99d615 refactor(ddg): passiv readout of event_status register 2025-08-05 12:31:54 +02:00
3c9192d6a5 refactor(ddg2): add check for negative pulse widths. 2025-08-05 12:31:42 +02:00
a01593fa4b refactor(ids_camera): add deprecation warning for ids_camera 2025-08-05 12:31:13 +02:00
gac-x12sa
40d6acf431 refactor: change sl1 names following convention of controls 2025-08-05 12:31:13 +02:00
9e45e927a0 test(ids-camera): add tests for the IDSCamera integration 2025-08-05 12:31:13 +02:00
8f7ada2f92 refactor(ids): refactor ids camera 2025-08-05 12:31:13 +02:00
gac-x12sa
b6af93806a fix: remove get_config from pre_startup 2025-08-05 12:31:13 +02:00
gac-x12sa
3db74d9877 fix: put idgap to readonly false 2025-07-22 11:57:12 +02:00
gac-x12sa
e6d7e0d82f refactor: rename ddg test config to endstation, add to first light 2025-07-22 11:57:12 +02:00
14713c0d2f fix: remove old mcs card from config 2025-07-22 11:57:12 +02:00
3fd3d54003 refactor(mcs-card): cleanup class, add tests 2025-07-21 22:27:14 +02:00
c2cba873d4 refactor: remove old mcs card 2025-07-21 22:27:14 +02:00
7679fa1383 fix: cleanup, fix mcs_clock 2025-07-21 22:27:14 +02:00
cdac29bed1 fix(xbpms): generalize describe method of sum,diff signals 2025-07-21 22:26:58 +02:00
7a3ce97e30 refactor: cleanup and formatting 2025-07-21 22:26:26 +02:00
gac-x12sa
62ecdd37ff refactor(mcs-card): fix mcs card integration at the beamline 2025-07-21 22:26:26 +02:00
256adb14d4 refactor(mcs-card): refactor mcs card integration 2025-07-21 22:26:26 +02:00
gac-x12sa
3cda1c74b4 fix(xbpm): add auto monitor and timestamp updates 2025-07-18 12:15:00 +02:00
e3077f6d2f feat(xbpms): add diag signal and normalize diff signals 2025-07-18 12:00:11 +02:00
a4c1b89395 refactor: update copier template to v1.1.2 2025-07-17 14:59:20 +02:00
gac-x12sa
909f0d2c96 fix: add sleep to ddg2 for pre_scan to be ready for triggers 2025-07-16 14:25:45 +02:00
4812323e4b refactor: cleanup and remove old classes from configs 2025-07-16 14:25:45 +02:00
809a8834fa test: add tests for ddg1 and ddg2 implementation at csaxs 2025-07-16 14:25:45 +02:00
98e4364176 feat: adding ddg1 and ddg2 logic for csaxs 2025-07-16 14:25:45 +02:00
173893ec33 refacto: remove old configs 2025-07-16 14:25:45 +02:00
1c539a1e9c refactor(ddg): final refactoring and cleanup 2025-07-16 14:25:45 +02:00
293e56fba8 feat(ddg): rewrite of the delay generator integration for cSAXS 2025-07-16 14:25:45 +02:00
gac-x12sa
44a4dfc818 add comments to mark devices as deprecated 2025-07-15 17:02:26 +02:00
gac-x12sa
53d1798c84 feat: added xbpm device and added in config 2025-07-15 16:56:01 +02:00
gac-x12sa
a4aa1da5dd add xbpm1 current monitors and fix X12SA-UIND in idgap 2025-07-15 15:31:52 +02:00
gac-x12sa
f332e06240 feat:add frontend xbpm and slit motors to device config 2025-07-11 15:46:44 +02:00
gac-x12sa
ef76e4b49b change xbpm1 to xbpm2 in optics hutch yaml file 2025-07-10 09:47:22 +02:00
gac-x12sa
6734143dc8 feat:add frontend motor and first_light config 2025-07-09 11:08:54 +02:00
gac-x12sa
66622fd75d change cu_foilx motor name to lower case c 2025-07-04 15:32:27 +02:00
gac-x12sa
e9fc6930ff add and test smaract stages inside OPbox 2025-07-04 15:26:06 +02:00
gac-x12sa
8ca1f00d1c add channel-cut motors and change motor names to shorter names 2025-07-04 14:28:25 +02:00
gac-x12sa
fad4611144 set readOnly to false for DMM motors and correct description 2025-07-01 11:42:39 +02:00
gac-x12sa
c51379a2cc feat:add initial optics hutch config 2025-06-27 16:21:08 +02:00
2baaaac1f3 fix: copier update for pre_startup 2025-06-11 16:53:34 +02:00
gac-x12sa
8eac0a2c38 wip 2025-06-11 16:45:51 +02:00
2894b1e5c2 wip test template for bec_config.yaml resolution 2025-06-11 16:45:51 +02:00
gac-x12sa
afd6590360 fix(pre-startup): update pre_startup get_config for test, production and local usage 2025-06-11 16:45:51 +02:00
63ee870c89 fix(test-x-ray-eye): update DeviceBase inits with proper config as needed due to PR #506 in BEC 2025-06-11 16:26:52 +02:00
36a2e55474 fix: update upstream repo for plugin template 2025-06-11 16:04:58 +02:00
19d117619a refactor: replace deprecated consumer/producer with connector 2025-06-04 19:38:30 +02:00
447a9c3924 refactor: upgrade to copier version v1 2025-05-24 11:37:51 +02:00
Holler Mirko
17e525689c initial version of omny alignment gui 2025-04-23 13:04:13 +02:00
123 changed files with 9647 additions and 4805 deletions

9
.copier-answers.yml Normal file
View File

@@ -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.2.2
_src_path: https://github.com/bec-project/plugin_copier_template.git
make_commit: false
project_name: csaxs_bec
widget_plugins_input: []

85
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,85 @@
name: CI for csaxs_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.11"
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.11' }}"
- name: Checkout BEC Core
run: git clone --depth 1 --branch "${{ inputs.BEC_CORE_BRANCH || 'main' }}" https://github.com/bec-project/bec.git ./bec
- name: Checkout Ophyd Devices
run: git clone --depth 1 --branch "${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}" https://github.com/bec-project/ophyd_devices.git ./ophyd_devices
- name: Checkout BEC Widgets
run: git clone --depth 1 --branch "${{ inputs.BEC_WIDGETS_BRANCH || 'main' }}" https://github.com/bec-project/bec_widgets.git ./bec_widgets
- name: Checkout BEC Plugin Repository
uses: actions/checkout@v4
with:
repository: bec/csaxs_bec
ref: "${{ inputs.BEC_PLUGIN_REPO_BRANCH || github.head_ref || github.sha }}"
path: ./csaxs_bec
- 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 ./csaxs_bec
- name: Run Pytest with Coverage
id: coverage
run: pytest --random-order --cov=./csaxs_bec --cov-config=./csaxs_bec/pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail ./csaxs_bec/tests/ || test $? -eq 5

View File

@@ -1,6 +1,7 @@
BSD 3-Clause License
Copyright (c) 2024, Paul Scherrer Institute
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:
@@ -25,4 +26,4 @@ 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.
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

1
bin/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
# Add anything you don't want to check in to git, e.g. very large files

View File

@@ -13,7 +13,6 @@ logger = bec_logger.logger
class PilatusConverter:
def __init__(self, host: str, port: int) -> None:
self._connector = RedisConnector(f"{host}:{port}")
self._producer = self._connector.producer()
def start(self) -> None:
"""start the consumer"""
@@ -57,10 +56,9 @@ class PilatusConverter:
"""
Start the consumer.
"""
file_consumer = self._connector.consumer(
self._connector.register(
MessageEndpoints.file_event("pilatus_2"), cb=self.on_new_message, parent=self
)
file_consumer.start()
if __name__ == "__main__":

View File

@@ -14,6 +14,8 @@ from typeguard import typechecked
from csaxs_bec.bec_ipython_client.plugins.cSAXS import cSAXSBeamlineChecks
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_optics_mixin import FlomniOpticsMixin
from csaxs_bec.bec_ipython_client.plugins.flomni.x_ray_eye_align import XrayEyeAlign
from csaxs_bec.bec_ipython_client.plugins.flomni.gui_tools import flomniGuiTools
from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import OMNYTools
logger = bec_logger.logger
@@ -24,27 +26,57 @@ if builtins.__dict__.get("bec") is not None:
umvr = builtins.__dict__.get("umvr")
class FlomniToolsError(Exception):
pass
class FlomniInitError(Exception):
pass
class FlomniError(Exception):
pass
class FlomniTools:
def yesno(self, message: str, default="none", autoconfirm=0) -> bool:
if autoconfirm and default == "y":
self.printgreen(message + " Automatically confirming default: yes")
return True
elif autoconfirm and default == "n":
self.printgreen(message + " Automatically confirming default: no")
return False
if default == "y":
message_ending = " [Y]/n? "
elif default == "n":
message_ending = " y/[N]? "
else:
message_ending = " y/n? "
while True:
user_input = input(self.OKBLUE + message + message_ending + self.ENDC)
if (
user_input == "Y" or user_input == "y" or user_input == "yes" or user_input == "Yes"
) or (default == "y" and user_input == ""):
return True
if (
user_input == "N" or user_input == "n" or user_input == "no" or user_input == "No"
) or (default == "n" and user_input == ""):
return False
else:
print("Please expicitely confirm y or n.")
class FlomniInitStagesMixin:
def flomni_init_stages(self):
user_input = input("Starting initialization of flOMNI stages. OK? [y/n]")
if user_input == "y":
if self.OMNYTools.yesno("Starting initialization of flOMNI stages. OK?"):
print("staring...")
else:
return
if self.check_all_axes_of_fomni_referenced():
user_input = input("Continue anyways? [y/n]")
if user_input == "y":
if self.OMNYTools.yesno("All axes are referenced. Continue anyways?"):
print("ok then...")
else:
return
@@ -74,10 +106,8 @@ class FlomniInitStagesMixin:
dev.feyex.limits = [-30, -1]
print("done")
user_input = input(
"Init of foptz. Can the stage move to the upstream limit without collision? [y/n]"
)
if user_input == "y":
if self.OMNYTools.yesno("Init of foptz. Can the stage move to the upstream limit without collision?"):
print("good then")
else:
return
@@ -131,10 +161,7 @@ class FlomniInitStagesMixin:
dev.fsamy.limits = [2, 3.1]
print("done")
user_input = input(
"Init of tracking stages. Did you remove the outer laser flight tubes? [y/n]"
)
if user_input == "y":
if self.OMNYTools.yesno("Init of tracking stages. Did you remove the outer laser flight tubes?"):
print("good then")
else:
print("Stopping.")
@@ -150,8 +177,7 @@ class FlomniInitStagesMixin:
dev.ftrackz.limits = [4.5, 5.5]
print("done")
user_input = input("Init of sample stage. Is the piezo at about 0 deg? [y/n]")
if user_input == "y":
if self.OMNYTools.yesno("Init of sample stage. Is the piezo at about 0 deg?"):
print("good then")
else:
print("Stopping.")
@@ -168,11 +194,7 @@ class FlomniInitStagesMixin:
print("done")
print("Initializing UPR stage.")
user_input = input(
"To ensure that the end switches work, please check that they are currently not pushed."
" Is everything okay? [y/n]"
)
if user_input == "y":
if self.OMNYTools.yesno("To ensure that the end switches work, please check that they are currently not pushed. Is everything okay?"):
print("good then")
else:
print("Stopping.")
@@ -193,8 +215,7 @@ class FlomniInitStagesMixin:
time.sleep(1)
continue
break
user_input = input("Shall I start the index search? [y/n]")
if user_input == "y":
if self.OMNYTools.yesno("Shall I start the index search?"):
print("good then. Starting index search.")
else:
print("Stopping.")
@@ -213,11 +234,7 @@ class FlomniInitStagesMixin:
dev.fsamroy.limits = [-5, 365]
print("done")
user_input = input(
"Init of foptx. Can the stage move to the positive limit without collision? Attention:"
" tracker flight tube! [y/n]"
)
if user_input == "y":
if self.OMNYTools.yesno("Init of foptx. Can the stage move to the positive limit without collision? Attention: tracker flight tube!"):
print("good then")
else:
print("Stopping.")
@@ -241,8 +258,7 @@ class FlomniInitStagesMixin:
continue
break
user_input = input("Start limit switch search of fopty? [y/n]")
if user_input == "y":
if self.OMNYTools.yesno("Start limit switch search of fopty?"):
print("good then")
else:
print("Stopping.")
@@ -275,8 +291,7 @@ class FlomniInitStagesMixin:
return False
def set_limits(self):
user_input = input("Set default limits for flOMNI? [y/n]")
if user_input == "y":
if self.OMNYTools.yesno("Set default limits for flOMNI?"):
print("setting limits...")
else:
print("Stopping.")
@@ -303,8 +318,7 @@ class FlomniInitStagesMixin:
dev.ftrackz.limits = [4.5, 5.5]
def _align_setup(self):
user_input = input("Start moving stages to default initial positions? [y/n]")
if user_input == "y":
if self.OMNYTools.yesno("Start moving stages to default initial positions?", "y"):
print("Start moving stages...")
else:
print("Stopping.")
@@ -397,7 +411,8 @@ class FlomniSampleTransferMixin:
raise FlomniError("Ftray is not at the 'IN' position. Aborting.")
def ftransfer_flomni_stage_in(self):
sample_in_position = bool(float(dev.flomni_samples.sample_placed.sample0.get()))
sample_in_position = dev.flomni_samples.is_sample_slot_used(0)
#bool(float(dev.flomni_samples.sample_placed.sample0.get()))
if not sample_in_position:
raise FlomniError("There is no sample in the sample stage. Aborting.")
self.reset_correction()
@@ -410,13 +425,15 @@ class FlomniSampleTransferMixin:
umv(dev.fsamx, fsamx_in)
dev.fsamx.limits = [fsamx_in - 0.4, fsamx_in + 0.4]
self.flomnigui_idle()
def laser_tracker_show_all(self):
dev.rtx.controller.laser_tracker_show_all()
def laser_tracker_on(self):
dev.rtx.controller.laser_tracker_on()
time.sleep(0.2)
self._laser_tracker_check_signalstrength()
dev.rtx.controller.laser_tracker_check_signalstrength()
def laser_tracker_off(self):
dev.rtx.controller.laser_tracker_off()
@@ -429,11 +446,11 @@ class FlomniSampleTransferMixin:
def feedback_enable_with_reset(self):
self.device_manager.devices.rtx.controller.feedback_enable_with_reset()
self.rt_feedback_status()
self.feedback_status()
def feedback_enable_without_reset(self):
self.device_manager.devices.rtx.controller.feedback_enable_without_reset()
self.rt_feedback_status()
self.feedback_status()
def feedback_status(self):
feedback_status = self.device_manager.devices.rtx.controller.feedback_is_running()
@@ -449,6 +466,10 @@ class FlomniSampleTransferMixin:
self.device_manager.devices.fsamx.controller.lights_on()
def ftransfer_flomni_stage_out(self):
self.flomnigui_show_cameras()
self.flomnigui_raise()
target_pos = -162
if np.isclose(dev.fsamx.readback.get(), target_pos, 0.01):
return
@@ -496,22 +517,20 @@ class FlomniSampleTransferMixin:
self.check_tray_in()
self.check_sensor_connected()
sample_in_gripper = bool(float(dev.flomni_samples.sample_in_gripper.get()))
sample_in_gripper = dev.flomni_samples.is_sample_in_gripper()
if sample_in_gripper:
raise FlomniError(
"The gripper does carry a sample. Cannot proceed getting another sample."
)
sample_signal = getattr(dev.flomni_samples.sample_placed, f"sample{position}")
sample_in_position = bool(float(sample_signal.get()))
sample_in_position = dev.flomni_samples.is_sample_slot_used(position)
if not sample_in_position:
raise FlomniError(f"The planned pick position [{position}] does not have a sample.")
user_input = input(
"Please confirm that there is currently no sample in the gripper. It would be dropped!"
" [y/n]"
)
if user_input == "y":
self.flomnigui_show_cameras()
if self.OMNYTools.yesno("Please confirm that there is currently no sample in the gripper. It would be dropped!", "y"):
print("good then")
else:
print("Stopping.")
@@ -555,12 +574,12 @@ class FlomniSampleTransferMixin:
self.check_tray_in()
self.check_sensor_connected()
sample_in_gripper = bool(float(dev.flomni_samples.sample_in_gripper.get()))
sample_in_gripper = dev.flomni_samples.is_sample_in_gripper()
#bool(float(dev.flomni_samples.sample_in_gripper.get()))
if not sample_in_gripper:
raise FlomniError("The gripper does not carry a sample.")
sample_signal = getattr(dev.flomni_samples.sample_placed, f"sample{position}")
sample_in_position = bool(float(sample_signal.get()))
sample_in_position = dev.flomni_samples.is_sample_slot_used(position)
if sample_in_position:
raise FlomniError(f"The planned put position [{position}] already has a sample.")
@@ -593,8 +612,9 @@ class FlomniSampleTransferMixin:
self.flomni_modify_storage_non_interactive(100, 0, "-")
self.flomni_modify_storage_non_interactive(position, 1, sample_name)
# TODO: flomni_stage_in if position == 0
# bec.queue.next_dataset_number += 1
if position == 0:
self.ftransfer_flomni_stage_in()
bec.queue.next_dataset_number += 1
def sample_get_name(self, position: int = 0) -> str:
"""
@@ -605,36 +625,51 @@ class FlomniSampleTransferMixin:
def ftransfer_sample_change(self, new_sample_position: int):
self.check_tray_in()
sample_in_gripper = dev.flomni_samples.sample_in_gripper.get()
# sample_in_gripper = dev.flomni_samples.sample_in_gripper.get()
sample_in_gripper = dev.flomni_samples.is_sample_in_gripper()
if sample_in_gripper:
raise FlomniError("There is already a sample in the gripper. Aborting.")
self.check_position_is_valid(new_sample_position)
sample_placed = getattr(
dev.flomni_samples.sample_placed, f"sample{new_sample_position}"
).get()
if new_sample_position == 0:
raise FlomniError("The new sample to place cannot be the sample in the sample stage. Aborting.")
# sample_placed = getattr(
# dev.flomni_samples.sample_placed, f"sample{new_sample_position}"
# ).get()
sample_placed = dev.flomni_samples.is_sample_slot_used(new_sample_position)
if not sample_placed:
raise FlomniError(
f"There is currently no sample in position [{new_sample_position}]. Aborting."
)
sample_in_sample_stage = dev.flomni_samples.sample_placed.sample0.get()
# sample_in_sample_stage = dev.flomni_samples.sample_placed.sample0.get()
sample_in_sample_stage = dev.flomni_samples.is_sample_slot_used(0)
if sample_in_sample_stage:
# find a new home for the sample...
empty_slots = []
for name, val in dev.flomni_samples.read().items():
if "flomni_samples_sample_placed_sample" not in name:
continue
if val.get("value") == 0:
empty_slots.append(int(name.split("flomni_samples_sample_placed_sample")[1]))
# for name, val in dev.flomni_samples.read().items():
# if "flomni_samples_sample_placed_sample" not in name:
# continue
# if val.get("value") == 0:
# empty_slots.append(int(name.split("flomni_samples_sample_placed_sample")[1]))
for j in range(1,20):
if not dev.flomni_samples.is_sample_slot_used(j):
empty_slots.append(j)
if not empty_slots:
raise FlomniError("There are no empty slots available. Aborting.")
print(f"The following slots are empty: {empty_slots}.")
while True:
user_input = input(f"Where shall I put the sample? Default: [{empty_slots[0]}]")
user_input = input(f"Where shall I put the sample? Default: [{empty_slots[0]}] ")
if user_input.strip() == "":
# No entry: use default
user_input = empty_slots[0]
break
try:
user_input = int(user_input)
if user_input not in empty_slots:
@@ -700,20 +735,20 @@ class FlomniSampleTransferMixin:
if confirm != -1:
return
user_input = input("All OK? Continue? [y/n]")
if user_input == "y":
if self.OMNYTools.yesno("All OK? Continue?", "y"):
print("good then")
dev.ftransy.controller.socket_put_confirmed("confirm=1")
else:
print("Stopping.")
return
exit
def ftransfer_gripper_is_open(self) -> bool:
status = bool(float(dev.ftransy.controller.socket_put_and_receive("MG @OUT[9]").strip()))
return status
def ftransfer_gripper_open(self):
sample_in_gripper = dev.flomni_samples.sample_in_gripper.get()
sample_in_gripper = dev.flomni_samples.is_sample_in_gripper()
#dev.flomni_samples.sample_in_gripper.get()
if sample_in_gripper:
raise FlomniError(
"Cannot open gripper. There is still a sample in the gripper! Aborting."
@@ -733,11 +768,8 @@ class FlomniSampleTransferMixin:
fsamx_pos = dev.fsamx.readback.get()
if position == 0 and fsamx_pos > -160:
user_input = input(
"May the flomni stage be moved out for the sample change? Feedback will be disabled"
" and alignment will be lost! [y/n]"
)
if user_input == "y":
if self.OMNYTools.yesno("May the flomni stage be moved out for the sample change? Feedback will be disabled and alignment will be lost!", "y"):
print("good then")
self.ftransfer_flomni_stage_out()
else:
@@ -892,7 +924,20 @@ class FlomniSampleTransferMixin:
class FlomniAlignmentMixin:
default_correction_file = "correction_flomni_20210300_360deg.txt"
import csaxs_bec
import os
from pathlib import Path
# Ensure this is a Path object, not a string
csaxs_bec_basepath = Path(csaxs_bec.__file__)
default_correction_file_rel = "correction_flomni_20210300_360deg.txt"
# Build the absolute path correctly
default_correction_file = (
csaxs_bec_basepath.parent / 'bec_ipython_client' / 'plugins' / 'flomni' / default_correction_file_rel
).resolve()
def reset_correction(self, use_default_correction=True):
"""
@@ -1106,6 +1151,7 @@ class Flomni(
FlomniAlignmentMixin,
FlomniOpticsMixin,
cSAXSBeamlineChecks,
flomniGuiTools
):
def __init__(self, client):
super().__init__()
@@ -1128,16 +1174,23 @@ class Flomni(
self.corr_pos_y_2 = []
self.corr_angle_y_2 = []
self.progress = {}
self.progress["subtomo"] = 0
self.progress["subtomo_projection"] = 0
self.progress["subtomo_total_projections"] = 1
self.progress["projection"] = 0
self.progress["total_projections"] = 1
self.progress["angle"] = 0
self.progress["tomo_type"] = 0
self.OMNYTools = OMNYTools(self.client)
self.align = XrayEyeAlign(self.client, self)
self.set_client(client)
def start_x_ray_eye_alignment(self):
user_input = input(
"Starting Xrayeye alignment. Deleting any potential existing alignment for this sample. [Y/n]"
)
if user_input == "y" or user_input == "":
def start_x_ray_eye_alignment(self, keep_shutter_open=False):
if self.OMNYTools.yesno("Starting Xrayeye alignment. Deleting any potential existing alignment for this sample.", "y"):
self.align = XrayEyeAlign(self.client, self)
try:
self.align.align()
self.align.align(keep_shutter_open)
except KeyboardInterrupt as exc:
fsamx_in = self._get_user_param_safe(dev.fsamx, "in")
if np.isclose(fsamx_in, dev.fsamx.readback.get(), 0.5):
@@ -1146,11 +1199,11 @@ class Flomni(
umv(dev.fsamx, fsamx_in)
raise exc
def xrayeye_update_frame(self):
self.align.update_frame()
def xrayeye_update_frame(self,keep_shutter_open=False):
self.align.update_frame(keep_shutter_open)
def xrayeye_alignment_start(self):
self.start_x_ray_eye_alignment()
def xrayeye_alignment_start(self, keep_shutter_open=False):
self.start_x_ray_eye_alignment(keep_shutter_open)
def drive_axis_to_limit(self, device, direction):
axis_id = device._config["deviceConfig"].get("axis_Id")
@@ -1570,6 +1623,9 @@ class Flomni(
def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None):
"""start a tomo scan"""
self.flomnigui_show_progress()
bec = builtins.__dict__.get("bec")
scans = builtins.__dict__.get("scans")
self._current_special_angles = self.special_angles.copy()
@@ -1706,6 +1762,7 @@ class Flomni(
print(f"Angle: ........................... {self.progress['angle']}")
print(f"Current subtomo: ................. {self.progress['subtomo']}")
print(f"Current projection within subtomo: {self.progress['subtomo_projection']}\x1b[0m")
self._flomnigui_update_progress()
def add_sample_database(
self, samplename, date, eaccount, scan_number, setup, sample_additional_info, user
@@ -1795,7 +1852,7 @@ class Flomni(
def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None:
tomo_scan_numbers_file = os.path.expanduser(
"~/Data10/specES1/dat-files/tomography_scannumbers.txt"
"~/tomography_scannumbers.txt"
)
with open(tomo_scan_numbers_file, "a+") as out_file:
# pylint: disable=undefined-variable
@@ -1894,8 +1951,8 @@ class Flomni(
)
print(f"\nSample name: {self.sample_name}\n")
user_input = input("Are these parameters correctly set for your scan? [Y/n]")
if user_input == "y" or user_input == "":
if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"):
print("... excellent!")
else:
self.tomo_countingtime = self._get_val("<ctime> s", self.tomo_countingtime, float)

View File

@@ -33,7 +33,7 @@ class FlomniOpticsMixin:
feyex_in = self._get_user_param_safe("feyex", "in")
feyey_in = self._get_user_param_safe("feyey", "in")
umv(dev.feyex, feyex_in, dev.feyey, feyey_in)
self.align.update_frame()
#self.align.update_frame()
def _ffzp_in(self):
foptx_in = self._get_user_param_safe("foptx", "in")

View File

@@ -0,0 +1,225 @@
import builtins
from bec_widgets.cli.client import BECDockArea
# from csaxs_bec.bec_ipython_client.plugins.cSAXS import epics_get, epics_put, fshopen, fshclose
if builtins.__dict__.get("bec") is not None:
bec = builtins.__dict__.get("bec")
dev = builtins.__dict__.get("dev")
umv = builtins.__dict__.get("umv")
umvr = builtins.__dict__.get("umvr")
class flomniGuiToolsError(Exception):
pass
class flomniGuiTools:
def __init__(self):
self.text_box = None
self.progressbar = None
def set_client(self, client):
self.client = client
self.gui = self.client.gui
def flomnigui_show_gui(self):
if "flomni" in self.gui.windows:
self.gui.flomni.show()
else:
self.gui.new("flomni")
def flomnigui_stop_gui(self):
self.gui.flomni.hide()
def flomnigui_raise(self):
self.gui.flomni.raise_window()
# def flomnigui_show_xeyealign(self):
# self.flomnigui_show_gui()
# if self.xeyegui is None:
# self.flomnigui_remove_all_docks()
# self.xeyegui = self.gui.flomni.new("xeyegui").new("XRayEye")
# # start live
# if not dev.cam_xeye.live_mode:
# dev.cam_xeye.live_mode = True
def flomnigui_show_xeyealign(self):
self.flomnigui_show_gui()
if self._flomnigui_check_attribute_not_exists("xeyegui"):
self.flomnigui_remove_all_docks()
self.xeyegui = self.gui.flomni.new("xeyegui").new("XRayEye")
# start live
if not dev.cam_xeye.live_mode:
dev.cam_xeye.live_mode = True
def _flomnigui_check_attribute_not_exists(self, attribute_name):
if hasattr(self.gui,"flomni"):
if hasattr(self.gui.flomni,attribute_name):
return False
return True
def flomnigui_show_cameras(self):
self.flomnigui_show_gui()
if self._flomnigui_check_attribute_not_exists("camera_gripper") or self._flomnigui_check_attribute_not_exists("camera_overview"):
self.flomnigui_remove_all_docks()
camera_gripper_image = self.gui.flomni.new("camera_gripper").new("Image")
if self._flomnicam_check_device_exists(dev.cam_flomni_gripper):
camera_gripper_image.image(("cam_flomni_gripper", "preview"))
camera_gripper_image.lock_aspect_ratio = True
camera_gripper_image.enable_fps_monitor = True
camera_gripper_image.enable_toolbar = False
camera_gripper_image.outer_axes = False
camera_gripper_image.inner_axes = False
dev.cam_flomni_gripper.start_live_mode()
else:
print("Cannot open camera_gripper. Device does not exist.")
camera_overview_image = self.gui.flomni.new("camera_overview").new("Image")
if self._flomnicam_check_device_exists(dev.cam_flomni_overview):
camera_overview_image.image(("cam_flomni_overview", "preview"))
camera_overview_image.lock_aspect_ratio = True
camera_overview_image.enable_fps_monitor = True
camera_overview_image.enable_toolbar = False
camera_overview_image.outer_axes = False
camera_overview_image.inner_axes = False
dev.cam_flomni_overview.start_live_mode()
else:
print("Cannot open camera_overview. Device does not exist.")
def flomnigui_remove_all_docks(self):
#dev.cam_flomni_overview.stop_live_mode()
#dev.cam_flomni_gripper.stop_live_mode()
#dev.cam_xeye.live_mode = False
self.gui.flomni.delete_all()
self.progressbar = None
self.text_box = None
def flomnigui_idle(self):
self.flomnigui_show_gui()
if self._flomnigui_check_attribute_not_exists("idle_text_box"):
self.flomnigui_remove_all_docks()
idle_text_box = self.gui.flomni.new("idle_textbox").new("TextBox")
text = (
"<pre>"
+ " ,---.,--. ,-----. ,--. ,--.,--. ,--.,--. \n"
+ "/ .-'| |' .-. '| `.' || ,'.| || | \n"
+ "| `-,| || | | || |'.'| || |' ' || | \n"
+ "| .-'| |' '-' '| | | || | ` || | \n"
+ "`--' `--' `-----' `--' `--'`--' `--'`--' \n"
+ "</pre>"
)
idle_text_box.set_html_text(text)
def flomnigui_docs(self, filename: str | None = None):
import csaxs_bec
from pathlib import Path
print("The general flOMNI documentation is at \nhttps://sls-csaxs.readthedocs.io/en/latest/user/ptychography/flomni.html#user-ptychography-flomni")
csaxs_bec_basepath = Path(csaxs_bec.__file__).parent
docs_folder = (
csaxs_bec_basepath /
"bec_ipython_client" / "plugins" / "flomni" / "docs"
)
if not docs_folder.is_dir():
raise NotADirectoryError(f"Docs folder not found: {docs_folder}")
pdfs = sorted(docs_folder.glob("*.pdf"))
if not pdfs:
raise FileNotFoundError(f"No PDF files found in {docs_folder}")
# --- Resolve PDF ------------------------------------------------------
if filename is not None:
pdf_file = docs_folder / filename
if not pdf_file.exists():
raise FileNotFoundError(f"Requested file not found: {filename}")
else:
print("\nAvailable flOMNI documentation PDFs:\n")
for i, pdf in enumerate(pdfs, start=1):
print(f" {i:2d}) {pdf.name}")
print()
while True:
try:
choice = int(input(f"Select a file (1{len(pdfs)}): "))
if 1 <= choice <= len(pdfs):
pdf_file = pdfs[choice - 1]
break
print(f"Enter a number between 1 and {len(pdfs)}.")
except ValueError:
print("Invalid input. Please enter a number.")
# --- GUI handling (active existence check) ----------------------------
self.flomnigui_show_gui()
if self._flomnigui_check_attribute_not_exists("PdfViewerWidget"):
self.flomnigui_remove_all_docks()
self.pdf_viewer = self.gui.flomni.new(widget="PdfViewerWidget")
# --- Load PDF ---------------------------------------------------------
self.pdf_viewer.PdfViewerWidget.load_pdf(str(pdf_file.resolve()))
print(f"\nLoaded: {pdf_file.name}\n")
def _flomnicam_check_device_exists(self, device):
try:
device
except:
return False
else:
return True
def flomnigui_show_progress(self):
self.flomnigui_show_gui()
if self._flomnigui_check_attribute_not_exists("progressbar"):
self.flomnigui_remove_all_docks()
# Add a new dock with a RingProgressBar widget
self.progressbar = self.gui.flomni.new("progressbar").new("RingProgressBar")
# Customize the size of the progress ring
self.progressbar.set_line_widths(20)
# Disable automatic updates and manually set the self.progressbar value
self.progressbar.enable_auto_updates(False)
# Set precision for the self.progressbar display
self.progressbar.set_precision(1) # Display self.progressbar with one decimal places
# Setting multiple rigns with different values
self.progressbar.set_number_of_bars(3)
self.progressbar.rings[0].set_update("manual")
self.progressbar.rings[1].set_update("manual")
self.progressbar.rings[2].set_update("scan")
# Set the values of the rings to 50, 75, and 25 from outer to inner ring
# self.progressbar.set_value([50, 75])
# Add a new dock with a TextBox widget
self.text_box = self.gui.flomni.new(name="progress_text").new("TextBox")
self._flomnigui_update_progress()
def _flomnigui_update_progress(self):
if self.progressbar is not None:
progress = self.progress["projection"] / self.progress["total_projections"] * 100
subtomo_progress = (
self.progress["subtomo_projection"]
/ self.progress["subtomo_total_projections"]
* 100
)
self.progressbar.set_value([progress, subtomo_progress, 0])
if self.text_box is not None:
text = f"Progress report:\n Tomo type: ....................... {self.progress['tomo_type']}\n Projection: ...................... {self.progress['projection']:.0f}\n Total projections expected ....... {self.progress['total_projections']}\n Angle: ........................... {self.progress['angle']}\n Current subtomo: ................. {self.progress['subtomo']}\n Current projection within subtomo: {self.progress['subtomo_projection']}\n Total projections per subtomo: ... {self.progress['subtomo_total_projections']}"
self.text_box.set_plain_text(text)
if __name__ == "__main__":
from bec_lib.client import BECClient
from bec_widgets.cli.client_utils import BECGuiClient
client = BECClient()
client.start()
client.gui = BECGuiClient()
flomni_gui = flomniGuiTools(client)
flomni_gui.flomnigui_show_gui()
flomni_gui.flomnigui_show_progress()

View File

@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
from bec_lib import bec_logger
from csaxs_bec.bec_ipython_client.plugins.cSAXS import epics_get, epics_put, fshopen
from csaxs_bec.bec_ipython_client.plugins.cSAXS import epics_get, epics_put, fshopen, fshclose
logger = bec_logger.logger
# import builtins to avoid linter errors
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
class XrayEyeAlign:
# pixel calibration, multiply to get mm
labview=False
PIXEL_CALIBRATION = 0.1 / 113 # .2 with binning
def __init__(self, client, flomni: Flomni) -> None:
@@ -40,28 +41,40 @@ class XrayEyeAlign:
def save_frame(self):
epics_put("XOMNYI-XEYE-SAVFRAME:0", 1)
def update_frame(self):
epics_put("XOMNYI-XEYE-ACQDONE:0", 0)
# start live
def update_frame(self,keep_shutter_open=False):
if self.labview:
epics_put("XOMNYI-XEYE-ACQDONE:0", 0)
if not self.labview:
self.flomni.flomnigui_show_xeyealign()
if not dev.cam_xeye.live_mode:
dev.cam_xeye.live_mode = True
epics_put("XOMNYI-XEYE-ACQ:0", 1)
# wait for start live
while epics_get("XOMNYI-XEYE-ACQDONE:0") == 0:
time.sleep(0.5)
print("waiting for live view to start...")
if self.labview:
# wait for start live
while epics_get("XOMNYI-XEYE-ACQDONE:0") == 0:
time.sleep(0.5)
print("waiting for live view to start...")
fshopen()
epics_put("XOMNYI-XEYE-ACQDONE:0", 0)
if self.labview:
epics_put("XOMNYI-XEYE-ACQDONE:0", 0)
while epics_get("XOMNYI-XEYE-ACQDONE:0") == 0:
print("waiting for new frame...")
time.sleep(0.5)
while epics_get("XOMNYI-XEYE-ACQDONE:0") == 0:
print("waiting for new frame...")
time.sleep(0.5)
time.sleep(0.5)
# stop live view
epics_put("XOMNYI-XEYE-ACQ:0", 0)
time.sleep(1)
# fshclose
print("got new frame")
if not keep_shutter_open:
epics_put("XOMNYI-XEYE-ACQ:0", 0)
time.sleep(0.1)
fshclose()
print("got new frame")
else:
print("Staying in live view, shutter is and remains open!")
def tomo_rotate(self, val: float):
# pylint: disable=undefined-variable
@@ -87,12 +100,23 @@ class XrayEyeAlign:
def send_message(self, msg: str):
epics_put("XOMNYI-XEYE-MESSAGE:0.DESC", msg)
def align(self):
def align(self,keep_shutter_open=False):
if not keep_shutter_open:
print("This routine can be called with paramter keep_shutter_open=True to keep the shutter always open")
self.send_message("Getting things ready. Please wait...")
#potential unresolved movement requests to zero
epics_put("XOMNYI-XEYE-MVX:0", 0)
epics_put("XOMNYI-XEYE-MVY:0", 0)
# reset shift xy and fov params
self._reset_init_values()
self.flomni.lights_off()
self.flomni.flomnigui_show_xeyealign()
self.flomni.flomnigui_raise()
self.tomo_rotate(0)
epics_put("XOMNYI-XEYE-ANGLE:0", 0)
@@ -119,7 +143,7 @@ class XrayEyeAlign:
umv(dev.fsamx, fsamx_in - 0.25)
self.flomni.ffzp_in()
self.update_frame()
self.update_frame(keep_shutter_open)
# enable submit buttons
self.movement_buttons_enabled = False
@@ -152,17 +176,18 @@ class XrayEyeAlign:
self.flomni.feedback_disable()
umv(dev.fsamx, fsamx_in - 0.25)
self.update_frame()
epics_put("XOMNYI-XEYE-RECBG:0", 1)
while epics_get("XOMNYI-XEYE-RECBG:0") == 1:
time.sleep(0.5)
print("waiting for background frame...")
if self.labview:
self.update_frame(keep_shutter_open)
epics_put("XOMNYI-XEYE-RECBG:0", 1)
while epics_get("XOMNYI-XEYE-RECBG:0") == 1:
time.sleep(0.5)
print("waiting for background frame...")
umv(dev.fsamx, fsamx_in)
time.sleep(0.5)
self.flomni.feedback_enable_with_reset()
self.update_frame()
self.update_frame(keep_shutter_open)
self.send_message("Adjust sample height and submit center")
epics_put("XOMNYI-XEYE-SUBMIT:0", 0)
self.movement_buttons_enabled = True
@@ -175,7 +200,7 @@ class XrayEyeAlign:
umv(dev.rtx, 0)
self.tomo_rotate(k * 45)
epics_put("XOMNYI-XEYE-ANGLE:0", self.get_tomo_angle())
self.update_frame()
self.update_frame(keep_shutter_open)
self.send_message("Submit sample center")
epics_put("XOMNYI-XEYE-SUBMIT:0", 0)
epics_put("XOMNYI-XEYE-ENAMVX:0", 1)
@@ -199,7 +224,7 @@ class XrayEyeAlign:
if k > 0:
epics_put(f"XOMNYI-XEYE-STAGEPOSX:{k}", dev.rtx.readback.get() / 1000)
time.sleep(3)
self.update_frame()
self.update_frame(keep_shutter_open)
if k < 2:
# allow movements, store movements to calculate center
@@ -210,7 +235,7 @@ class XrayEyeAlign:
time.sleep(2)
epics_put("XOMNYI-XEYE-MVY:0", 0)
self.flomni.feedback_enable_with_reset()
self.update_frame()
self.update_frame(keep_shutter_open)
time.sleep(0.2)
self.write_output()
@@ -221,8 +246,16 @@ class XrayEyeAlign:
umv(dev.rtx, 0)
# free camera
epics_put("XOMNYI-XEYE-ACQ:0", 2)
# free camera
if self.labview:
epics_put("XOMNYI-XEYE-ACQ:0", 2)
if keep_shutter_open and not self.labview:
if self.flomni.OMNYTools.yesno("Close the shutter now?","y"):
fshclose()
epics_put("XOMNYI-XEYE-ACQ:0", 0)
if not self.labview:
self.flomni.flomnigui_idle()
print(
f"The largest field of view from the xrayeyealign was \nfovx = {fovx:.0f} microns, fovy"

View File

@@ -0,0 +1,250 @@
"""Module providing debugging tools for the BEC IPython client at cSAXS."""
from __future__ import annotations
import inspect
import json
import os
import re
import socket
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from typing import TYPE_CHECKING, Literal
import numpy as np
from pydantic import BaseModel
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from slugify import slugify
if TYPE_CHECKING:
from bec_ipython_client.main import BECIPythonClient
from bec_lib.devicemanager import DeviceManagerBase
from bec_lib.scans import Scans
from bec_widgets.cli.client_utils import BECGuiClient
scans: Scans # type: ignore[no-redef]
bec: BECIPythonClient # type: ignore[no-redef]
dev: DeviceManagerBase # type: ignore[no-redef]
class Detector(BaseModel):
"""Model representing a detector configuration."""
name: str
hostnames: list[str]
cfg: dict
def to_identifier(text: str) -> str:
"""
Convert an unsafe string into a valid Python identifier.
"""
name = slugify(text.strip(), separator="_")
name = re.sub(r"[^a-zA-Z0-9_]", "", name)
if not name:
raise ValueError(f"Cannot convert '{text}' to a valid identifier.")
if name[0].isdigit():
name = f"_{name}"
return name
class DebugTools:
"""A collection of debugging tools for the BEC IPython client at cSAXS."""
_PURPOSE = (
"Debugging helpers for the cSAXS BEC IPython client. These tools are intended for advanced users "
"and developers to diagnose and troubleshoot issues within the BEC environment. "
"Below are the available methods together with a brief description of their functionality."
)
######################
## Internal Methods ##
######################
def _describe(self) -> None:
"""Pretty-print a description of this debugging tool."""
console = Console()
# Offset for IPython prompt misplacement
console.print("\n\n", end="")
header = Text("DebugTools", style="bold cyan")
purpose = Text(self._PURPOSE, style="dim")
console.print(Panel(purpose, title=header, expand=False))
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Method", style="bold", no_wrap=True)
table.add_column("Description")
for name, member in inspect.getmembers(self, predicate=inspect.ismethod):
if name.startswith("_"):
continue
doc = inspect.getdoc(member)
short_doc = doc.splitlines()[0] if doc else ""
table.add_row(name, short_doc)
console.print(table)
def _repr_pretty_(self, p, cycle: bool) -> None:
if cycle:
p.text("DebugTools(...)")
else:
self._describe()
#####################
### MCS Card Check ###
#####################
def _check_if_device_is_loaded(self, device_name: str):
"""Check if a device is loaded in the current BEC session."""
if device_name not in dev:
raise RuntimeError(
f"Device {device_name} was not loaded in the current active BEC session."
)
def mcs_test_acquire(
self, mode: Literal["high_frame", "medium_frame", "low_frame"] = "high_frame"
):
"""
Method to perform a test acquisition with randomized exposure time, burst frames, and cycles
on the MCS card using the DDG trigger setup.
Args:
mode (Literal["high_frame", "medium_frame", "low_frame"]): The mode of the test.
- 'high_frame': Tests high frame rates with short exposure times.
- 'medium_frame': Tests medium frame rates with moderate exposure times.
- 'low_frame': Tests low frame rates with longer exposure times.
"""
self._check_if_device_is_loaded("mcs")
self._check_if_device_is_loaded("ddg1")
self._check_if_device_is_loaded("ddg2")
if mode == "high_frame":
burst_frames = np.random.randint(10_000, 100_000) # between 10000 and 100000
cycles = np.random.randint(5, 20) # between 5 and 20
exp_time = (
np.random.rand() * (0.001 - 0.201e-3) + 0.201e-3
) # between 0.000201 ms and 0.001 s
elif mode == "medium_frame":
burst_frames = np.random.randint(50, 500) # between 50 and 500
cycles = np.random.randint(1, 10) # between 1 and 10
exp_time = np.random.rand() * (0.01 - 0.001) + 0.001 # between 0.001 ms and 0.01 s
elif mode == "low_frame":
burst_frames = np.random.randint(5, 20) # between 5 and 20
cycles = np.random.randint(1, 5) # between 1 and 5
exp_time = np.random.rand() * (2 - 0.1) + 0.1 # between 0.1 ms and 2 s
else:
raise ValueError(f"Invalid mode '{mode}' specified for acquire scan test.")
print(
f"Starting acquire measurement with exp_time={exp_time:.6f}, burst_frames={burst_frames}, cycles={cycles}"
)
s = scans.acquire(
exp_time=exp_time, frames_per_trigger=burst_frames, burst_at_each_point=cycles
)
s.wait(file_written=True)
print("Acquire measurement finished.")
print("Checking MCS data...")
scan_data = bec.history.get_by_scan_id(s.scan.scan_id)
mcs_data = scan_data.devices.mcs
print(mcs_data)
shape = mcs_data._info["mcs_mca_mca1"]["value"]["shape"]
expected_shape = (cycles * burst_frames,)
# Assert will raise an error if the shapes do not match
assert (
shape == expected_shape
), f"MCS data shape {shape} does not match expected shape {expected_shape}."
########################
### JFJ/Eiger Checks ###
########################
def _get_jfj_eiger_config(self) -> dict[str, Detector]:
"""Retrieve the current JFJ/Eiger detector configuration from the BEC client."""
# FIXME: Implement REST API call once ready for use from Leo Sala's team.
ret = {}
base_path = os.path.dirname(__file__)
config_path = os.path.join(base_path, "jfj_config.json")
with open(config_path, "r", encoding="utf-8") as fh:
cfg = json.load(fh)
for entry in cfg["detector"]:
det = Detector(
name=to_identifier(entry["description"]), hostnames=entry["hostname"], cfg=cfg
)
ret[det.name] = det
return ret
def list_detectors(self) -> list[str]:
"""
List the names of all JFJ/Eiger detectors configured in the BEC client.
Returns:
list[str]: A list of detector names.
"""
detectors = self._get_jfj_eiger_config()
return list(detectors.keys())
def ping_detector(self, detector_name: str) -> bool:
"""
Ping a JFJ/Eiger detector to check if it is reachable.
Args:
detector_name (str): The name of the detector to ping.
Returns:
bool: True if the detector is reachable, False otherwise.
"""
detectors = self._get_jfj_eiger_config()
if detector_name not in detectors:
raise ValueError(f"Detector '{detector_name}' not found in configuration.")
det = detectors[detector_name]
results = self._ping_many(det.hostnames)
table = Table(title=f"Ping results for detector '{detector_name}'")
table.add_column("Hostname", style="cyan", no_wrap=True)
table.add_column("Status", style="magenta")
for host, alive in results.items():
status = "[green]OK[/green]" if alive else "[red]DOWN[/red]"
table.add_row(host, status)
console = Console()
console.print(table)
def _ping_many(self, hosts: list[str], port=22, timeout=2, max_workers=None):
max_workers = max_workers or len(hosts)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
primed_ping = partial(self._ping, port=port, timeout=timeout)
pings = executor.map(primed_ping, hosts)
return dict(zip(hosts, pings))
def _ping(self, host: str, port=23, timeout=2): # telnet is port 23
address = (host, port)
try:
with socket.create_connection(address, timeout):
return True
except OSError:
return False
def open_it_service_page(self):
"""Open the overview of IT services hosted by Science IT Infrastructure and Services for cSAXS."""
gui: BECGuiClient = bec.gui
dock_area = gui.new()
print("Opening IT service page in new dock...")
url = "https://metrics.psi.ch/d/saf8mxv/x12sa?orgId=1&from=now-24h&to=now&timezone=browser&var-receiver_hosts=sls-jfjoch-001.psi.ch&var-writer_hosts=xbl-daq-34.psi.ch&var-beamline=X12SA&var-slurm_partitions=csaxs&var-receiver_services=broker&var-writer_services=writer&refresh=15m"
# FIXME BEC WIDGETS v3
dock = dock_area.new()
wb = dock.new(widget=gui.available_widgets.WebsiteWidget)
wb.set_url(url)

View File

@@ -0,0 +1,162 @@
{
"zeromq" : {
"image_socket": ["tcp://0.0.0.0:5500"]
},
"zeromq_preview": {
"socket_address": "tcp://0.0.0.0:5400",
"enabled": true,
"period_ms": 1000
},
"zeromq_metadata" : {
"socket_address": "tcp://0.0.0.0:5600",
"enabled": true,
"period_ms": 100
},
"instrument" : {
"source_name": "Swiss Light Source",
"instrument_name": "cSAXS",
"source_type": "Synchrotron X-ray Source"
},
"detector": [
{
"description": "EIGER 9M",
"serial_number": "E1",
"type": "EIGER",
"mirror_y": true,
"base_data_ipv4_address": "10.10.10.10",
"calibration_file":["/opt/jfjoch/calibration/"],
"standard_geometry" : {
"nmodules": 18,
"modules_in_row": 3,
"gap_x": 8,
"gap_y": 36
},
"hostname": [
"beb101",
"beb103",
"beb014",
"beb078",
"beb060",
"beb030",
"beb092",
"beb178",
"beb009",
"beb038",
"beb056",
"beb058",
"beb033",
"beb113",
"beb005",
"beb017",
"beb119",
"beb095",
"beb186",
"beb042",
"beb106",
"beb059",
"beb111",
"beb203",
"beb100",
"beb093",
"beb123",
"beb061",
"beb121",
"beb055",
"beb004",
"beb190",
"beb054",
"beb189",
"beb107",
"beb115"
]
},
{
"description": "EIGER 8.5M (tmp)",
"serial_number": "E1-tmp",
"type": "EIGER",
"mirror_y": true,
"base_data_ipv4_address": "10.10.10.10",
"calibration_file":["/opt/jfjoch/calibration/"],
"standard_geometry" : {
"nmodules": 17,
"modules_in_row": 3,
"gap_x": 8,
"gap_y": 36
},
"hostname": [
"beb101",
"beb103",
"beb014",
"beb078",
"beb060",
"beb030",
"beb092",
"beb178",
"beb009",
"beb038",
"beb056",
"beb058",
"beb033",
"beb113",
"beb005",
"beb017",
"beb119",
"beb095",
"beb186",
"beb042",
"beb106",
"beb059",
"beb100",
"beb093",
"beb123",
"beb061",
"beb121",
"beb055",
"beb004",
"beb190",
"beb054",
"beb189",
"beb107",
"beb115"
]
},
{
"description": "EIGER 1.5M",
"serial_number": "E2",
"type": "EIGER",
"mirror_y": true,
"base_data_ipv4_address": "10.10.11.10",
"calibration_file":["/opt/jfjoch/calibration_e1p5m/"],
"standard_geometry" : {
"nmodules": 3,
"modules_in_row": 1,
"gap_x": 8,
"gap_y": 36
},
"hostname": ["beb062", "beb026", "beb099", "beb084", "beb120", "beb108"]
}
],
"frontend_directory": "/usr/share/jfjoch/frontend/",
"image_pusher": "ZeroMQ",
"numa_policy": "n2g2",
"receiver_threads": 64,
"image_buffer_MiB": 96000,
"pcie": [
{
"blk": "/dev/jfjoch0",
"ipv4": "10.10.10.1"
},
{
"blk": "/dev/jfjoch1",
"ipv4": "10.10.10.2"
},
{
"blk": "/dev/jfjoch2",
"ipv4": "10.10.10.3"
},
{
"blk": "/dev/jfjoch3",
"ipv4": "10.10.10.4"
}
]
}

View File

@@ -48,6 +48,11 @@ elif _args.session.lower() == "csaxs":
logger.success("cSAXS session loaded.")
from csaxs_bec.bec_ipython_client.plugins.tool_box.debug_tools import DebugTools
debug = DebugTools()
logger.success("Debug tools loaded. Use 'debug' to access them.")
# SETUP BEAMLINE INFO
from bec_ipython_client.plugins.SLS.sls_info import OperatorInfo, SLSInfo
@@ -59,27 +64,5 @@ bec._beamline_mixin._bl_info_register(SLSInfo)
bec._beamline_mixin._bl_info_register(OperatorInfo)
# SETUP PROMPTS
bec._ip.prompts.username = _session_name
bec._ip.prompts.session_name = _session_name
bec._ip.prompts.status = 1
# REGISTER BEAMLINE CHECKS
from bec_lib.bl_conditions import (
FastOrbitFeedbackCondition,
LightAvailableCondition,
ShutterCondition,
)
if "sls_machine_status" in dev:
print("Registering light available condition for SLS machine status")
_light_available_condition = LightAvailableCondition(dev.sls_machine_status)
bec.bl_checks.register(_light_available_condition)
if "x12sa_es1_shutter_status" in dev:
print("Registering shutter condition for X12SA ES1 shutter status")
_shutter_condition = ShutterCondition(dev.x12sa_es1_shutter_status)
bec.bl_checks.register(_shutter_condition)
# if hasattr(dev, "sls_fast_orbit_feedback"):
# print("Registering fast orbit feedback condition for SLS fast orbit feedback")
# _fast_orbit_feedback_condition = FastOrbitFeedbackCondition(dev.sls_fast_orbit_feedback)
# bec.bl_checks.register(_fast_orbit_feedback_condition)

View File

@@ -1,8 +1,14 @@
"""
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.
is started. It can be used to add additional command line arguments.
"""
import os
from bec_lib.service_config import ServiceConfig
import csaxs_bec
def extend_command_line_args(parser):
"""
@@ -16,6 +22,11 @@ def extend_command_line_args(parser):
# def get_config() -> ServiceConfig:
# """
# Create and return the service configuration.
# Create and return the ServiceConfig for the plugin repository
# """
# return ServiceConfig(redis={"host": "localhost", "port": 6379})
# deployment_path = os.path.dirname(os.path.dirname(os.path.dirname(csaxs_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})

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import ScanStatusMessage
class cSAXSUpdate(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)
if 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.
"""
class cSAXSUpdateAlignment(AutoUpdates): ...
class cSAXSUpdateScan(AutoUpdates): ...

View File

@@ -0,0 +1,148 @@
# 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 = {
"OmnyAlignment": "OmnyAlignment",
"XRayEye": "XRayEye",
}
class OmnyAlignment(RPCBase):
@property
@rpc_call
def enable_live_view(self):
"""
None
"""
@enable_live_view.setter
@rpc_call
def enable_live_view(self):
"""
None
"""
@property
@rpc_call
def user_message(self):
"""
None
"""
@user_message.setter
@rpc_call
def user_message(self):
"""
None
"""
@property
@rpc_call
def sample_name(self):
"""
None
"""
@sample_name.setter
@rpc_call
def sample_name(self):
"""
None
"""
@property
@rpc_call
def enable_move_buttons(self):
"""
None
"""
@enable_move_buttons.setter
@rpc_call
def enable_move_buttons(self):
"""
None
"""
class XRayEye(RPCBase):
@rpc_call
def active_roi(self) -> "BaseROI | None":
"""
Return the currently active ROI, or None if no ROI is active.
"""
@property
@rpc_call
def enable_live_view(self):
"""
Get or set the live view enabled state.
"""
@enable_live_view.setter
@rpc_call
def enable_live_view(self):
"""
Get or set the live view enabled state.
"""
@property
@rpc_call
def user_message(self):
"""
None
"""
@user_message.setter
@rpc_call
def user_message(self):
"""
None
"""
@property
@rpc_call
def sample_name(self):
"""
None
"""
@sample_name.setter
@rpc_call
def sample_name(self):
"""
None
"""
@property
@rpc_call
def enable_move_buttons(self):
"""
None
"""
@enable_move_buttons.setter
@rpc_call
def enable_move_buttons(self):
"""
None
"""
class XRayEye2DControl(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""

View File

@@ -0,0 +1,140 @@
from typing import TypedDict
from bec_widgets.utils.error_popups import SafeSlot
import os
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.ui_loader import UILoader
from qtpy.QtWidgets import QWidget, QPushButton, QLineEdit, QLabel, QVBoxLayout
from bec_qthemes import material_icon
from bec_lib.logger import bec_logger
logger = bec_logger.logger
# class OmnyAlignmentUIComponents(TypedDict):
# moveRightButton: QPushButton
# moveLeftButton: QPushButton
# moveUpButton: QPushButton
# moveDownButton: QPushButton
# image: Image
class OmnyAlignment(BECWidget, QWidget):
USER_ACCESS = ["enable_live_view", "enable_live_view.setter", "user_message", "user_message.setter","sample_name", "sample_name.setter", "enable_move_buttons", "enable_move_buttons.setter"]
PLUGIN = True
ui_file = "./omny_alignment.ui"
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)
icon_options = {"size": (16, 16), "convert_to_pixmap": False}
self.ui.moveRightButton.setText("")
self.ui.moveRightButton.setIcon(
material_icon(icon_name="keyboard_arrow_right", **icon_options)
)
self.ui.moveLeftButton.setText("")
self.ui.moveLeftButton.setIcon(
material_icon(icon_name="keyboard_arrow_left", **icon_options)
)
self.ui.moveUpButton.setText("")
self.ui.moveUpButton.setIcon(
material_icon(icon_name="keyboard_arrow_up", **icon_options)
)
self.ui.moveDownButton.setText("")
self.ui.moveDownButton.setIcon(
material_icon(icon_name="keyboard_arrow_down", **icon_options)
)
self.ui.confirmButton.setText("OK")
self.ui.liveViewSwitch.enabled.connect(self.on_live_view_enabled)
# self.ui.moveUpButton.clicked.connect(self.on_move_up)
@property
def enable_live_view(self):
return self.ui.liveViewSwitch.checked
@enable_live_view.setter
def enable_live_view(self, enable:bool):
self.ui.liveViewSwitch.checked = enable
@property
def user_message(self):
return self.ui.messageLineEdit.text()
@user_message.setter
def user_message(self, message:str):
self.ui.messageLineEdit.setText(message)
@property
def sample_name(self):
return self.ui.sampleLineEdit.text()
@sample_name.setter
def sample_name(self, message:str):
self.ui.sampleLineEdit.setText(message)
@SafeSlot(bool)
def on_live_view_enabled(self, enabled:bool):
from bec_widgets.widgets.plots.image.image import Image
logger.info(f"Live view is enabled: {enabled}")
image: Image = self.ui.image
if enabled:
image.image("cam_xeye")
return
image.disconnect_monitor("cam_xeye")
@property
def enable_move_buttons(self):
move_up:QPushButton = self.ui.moveUpButton
move_down:QPushButton = self.ui.moveDownButton
move_left:QPushButton = self.ui.moveLeftButton
move_right:QPushButton = self.ui.moveRightButton
return move_up.isEnabled() and move_down.isEnabled() and move_left.isEnabled() and move_right.isEnabled()
@enable_move_buttons.setter
def enable_move_buttons(self, enabled:bool):
move_up:QPushButton = self.ui.moveUpButton
move_down:QPushButton = self.ui.moveDownButton
move_left:QPushButton = self.ui.moveLeftButton
move_right:QPushButton = self.ui.moveRightButton
move_up.setEnabled(enabled)
move_down.setEnabled(enabled)
move_left.setEnabled(enabled)
move_right.setEnabled(enabled)
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
import sys
app = QApplication(sys.argv)
widget = OmnyAlignment()
widget.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1 @@
{'files': ['omny_alignment.py']}

View File

@@ -0,0 +1,125 @@
<?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>988</width>
<height>821</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="2" column="2">
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="2">
<widget class="QPushButton" name="moveRightButton">
<property name="text">
<string>PushButton</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QPushButton" name="moveLeftButton">
<property name="text">
<string>PushButton</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QPushButton" name="moveUpButton">
<property name="text">
<string>Up</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="moveDownButton">
<property name="text">
<string>PushButton</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPushButton" name="confirmButton">
<property name="text">
<string>PushButton</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="0">
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="1">
<widget class="QLineEdit" name="sampleLineEdit"/>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="messageLineEdit"/>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Sample</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Message</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="1" column="0" colspan="3">
<widget class="Image" name="image">
<property name="enable_toolbar" stdset="0">
<bool>false</bool>
</property>
<property name="inner_axes" stdset="0">
<bool>false</bool>
</property>
<property name="monitor" stdset="0">
<string>cam_xeye</string>
</property>
<property name="rotation" stdset="0">
<number>3</number>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="ToggleSwitch" name="liveViewSwitch"/>
</item>
<item row="0" column="2">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Live View</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>Image</class>
<extends>QWidget</extends>
<header>image</header>
</customwidget>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -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 csaxs_bec.bec_widgets.widgets.omny_alignment.omny_alignment import OmnyAlignment
DOM_XML = """
<ui language='c++'>
<widget class='OmnyAlignment' name='omny_alignment'>
</widget>
</ui>
"""
class OmnyAlignmentPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = OmnyAlignment(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(OmnyAlignment.ICON_NAME)
def includeFile(self):
return "omny_alignment"
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 "OmnyAlignment"
def toolTip(self):
return "OmnyAlignment"
def whatsThis(self):
return self.toolTip()

View File

@@ -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 csaxs_bec.bec_widgets.widgets.omny_alignment.omny_alignment_plugin import OmnyAlignmentPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(OmnyAlignmentPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -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 csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye_plugin import XRayEyePlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(XRayEyePlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,426 @@
from __future__ import annotations
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from bec_widgets import BECWidget, SafeProperty, SafeSlot
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
from bec_widgets.widgets.plots.roi.image_roi import BaseROI, CircularROI, RectangularROI
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import (
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
logger = bec_logger.logger
CAMERA = ("cam_xeye", "image")
class XRayEye2DControl(BECWidget, QWidget):
def __init__(self, parent=None, step_size: int = 100, *arg, **kwargs):
super().__init__(parent=parent, *arg, **kwargs)
self.get_bec_shortcuts()
self._step_size = step_size
self.root_layout = QGridLayout(self)
self.setStyleSheet("""
QToolButton {
border: 1px solid;
border-radius: 4px;
}
""")
# Up
self.move_up_button = QToolButton(parent=self)
self.move_up_button.setIcon(material_icon('keyboard_double_arrow_up'))
self.root_layout.addWidget(self.move_up_button, 0, 2)
# Up tweak button
self.move_up_tweak_button = QToolButton(parent=self)
self.move_up_tweak_button.setIcon(material_icon('keyboard_arrow_up'))
self.root_layout.addWidget(self.move_up_tweak_button, 1, 2)
# Left
self.move_left_button = QToolButton(parent=self)
self.move_left_button.setIcon(material_icon('keyboard_double_arrow_left'))
self.root_layout.addWidget(self.move_left_button, 2, 0)
# Left tweak button
self.move_left_tweak_button = QToolButton(parent=self)
self.move_left_tweak_button.setIcon(material_icon('keyboard_arrow_left'))
self.root_layout.addWidget(self.move_left_tweak_button, 2, 1)
# Right
self.move_right_button = QToolButton(parent=self)
self.move_right_button.setIcon(material_icon('keyboard_double_arrow_right'))
self.root_layout.addWidget(self.move_right_button, 2, 4)
# Right tweak button
self.move_right_tweak_button = QToolButton(parent=self)
self.move_right_tweak_button.setIcon(material_icon('keyboard_arrow_right'))
self.root_layout.addWidget(self.move_right_tweak_button, 2, 3)
# Down
self.move_down_button = QToolButton(parent=self)
self.move_down_button.setIcon(material_icon('keyboard_double_arrow_down'))
self.root_layout.addWidget(self.move_down_button, 4, 2)
# Down tweak button
self.move_down_tweak_button = QToolButton(parent=self)
self.move_down_tweak_button.setIcon(material_icon('keyboard_arrow_down'))
self.root_layout.addWidget(self.move_down_tweak_button, 3, 2)
# Connections
self.move_up_button.clicked.connect(lambda: self.move("up", tweak=False))
self.move_up_tweak_button.clicked.connect(lambda: self.move("up", tweak=True))
self.move_down_button.clicked.connect(lambda: self.move("down", tweak=False))
self.move_down_tweak_button.clicked.connect(lambda: self.move("down", tweak=True))
self.move_left_button.clicked.connect(lambda: self.move("left", tweak=False))
self.move_left_tweak_button.clicked.connect(lambda: self.move("left", tweak=True))
self.move_right_button.clicked.connect(lambda: self.move("right", tweak=False))
self.move_right_tweak_button.clicked.connect(lambda: self.move("right", tweak=True))
@SafeProperty(int)
def step_size(self) -> int:
return self._step_size
@step_size.setter
def step_size(self, step_size: int):
self._step_size = step_size
@SafeSlot(bool)
def enable_controls_hor(self, enable: bool):
self.move_left_button.setEnabled(enable)
self.move_left_tweak_button.setEnabled(enable)
self.move_right_button.setEnabled(enable)
self.move_right_tweak_button.setEnabled(enable)
@SafeSlot(bool)
def enable_controls_ver(self, enable: bool):
self.move_up_button.setEnabled(enable)
self.move_up_tweak_button.setEnabled(enable)
self.move_down_button.setEnabled(enable)
self.move_down_tweak_button.setEnabled(enable)
def move(self, direction: str, tweak: bool = False):
step = self._step_size
if tweak:
step = int(self._step_size / 5)
if direction == "up":
self.dev.omny_xray_gui.mvy.set(step)
elif direction == "down":
self.dev.omny_xray_gui.mvy.set(-step)
elif direction == "left":
self.dev.omny_xray_gui.mvx.set(-step)
elif direction == "right":
self.dev.omny_xray_gui.mvx.set(step)
else:
logger.warning(f"Unknown direction {direction} for move command.")
class XRayEye(BECWidget, QWidget):
USER_ACCESS = ["active_roi", "enable_live_view", "enable_live_view.setter", "user_message", "user_message.setter",
"sample_name", "sample_name.setter", "enable_move_buttons", "enable_move_buttons.setter"]
PLUGIN = True
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self.get_bec_shortcuts()
self._init_ui()
self._make_connections()
# Connection to redis endpoints
self.bec_dispatcher.connect_slot(self.device_updates, MessageEndpoints.device_readback("omny_xray_gui"))
self.connect_motors()
self.resize(800, 600)
QTimer.singleShot(0, self._init_gui_trigger)
def _init_ui(self):
self.core_layout = QHBoxLayout(self)
self.image = Image(parent=self)
self.image.enable_toolbar = False # Disable default toolbar to not allow to user set anything
self.image.inner_axes = False # Disable inner axes to maximize image area
self.image.plot_item.vb.invertY(True) # #TODO Invert y axis to match logic of LabView GUI
# Control panel on the right: vertical layout inside a fixed-width widget
self.control_panel = QWidget(parent=self)
self.control_panel_layout = QVBoxLayout(self.control_panel)
self.control_panel_layout.setContentsMargins(0, 0, 0, 0)
self.control_panel_layout.setSpacing(10)
# ROI toolbar + Live toggle (header row)
self.roi_manager = ROIPropertyTree(parent=self, image_widget=self.image, compact=True,
compact_orientation="horizontal")
header_row = QHBoxLayout()
header_row.setContentsMargins(0, 0, 0, 0)
header_row.setSpacing(8)
header_row.addWidget(self.roi_manager, 0)
header_row.addStretch()
self.live_preview_label = QLabel("Live Preview", parent=self)
self.live_preview_toggle = ToggleSwitch(parent=self)
self.live_preview_toggle.checked = False
header_row.addWidget(self.live_preview_label, 0, Qt.AlignVCenter)
header_row.addWidget(self.live_preview_toggle, 0, Qt.AlignVCenter)
self.control_panel_layout.addLayout(header_row)
# separator
self.control_panel_layout.addWidget(self._create_separator())
# 2D Positioner (fixed size)
self.motor_control_2d = XRayEye2DControl(parent=self)
self.control_panel_layout.addWidget(self.motor_control_2d, 0, Qt.AlignTop | Qt.AlignCenter)
# separator
self.control_panel_layout.addWidget(self._create_separator())
# Step size label
step_size_form = QGridLayout()
# General Step size
self.step_size = QSpinBox(parent=self)
self.step_size.setRange(10, 100)
self.step_size.setSingleStep(10)
self.step_size.setValue(100)
# Submit button
self.submit_button = QPushButton("Submit", parent=self)
# Add to layout form
step_size_form.addWidget(QLabel("Horizontal", parent=self), 0, 0)
step_size_form.addWidget(self.step_size, 0, 1)
step_size_form.addWidget(QLabel("Vertical", parent=self), 1, 0)
step_size_form.addWidget(self.submit_button, 2, 0, 1, 2)
# Add form to control panel
self.control_panel_layout.addLayout(step_size_form)
# Push form to bottom
self.control_panel_layout.addStretch()
# Sample/Message form (bottom)
form = QGridLayout()
self.sample_name_line_edit = QLineEdit(parent=self)
self.sample_name_line_edit.setReadOnly(True)
form.addWidget(QLabel("Sample", parent=self), 0, 0)
form.addWidget(self.sample_name_line_edit, 0, 1)
self.message_line_edit = QLineEdit(parent=self)
self.message_line_edit.setReadOnly(True)
form.addWidget(QLabel("Message", parent=self), 1, 0)
form.addWidget(self.message_line_edit, 1, 1)
self.control_panel_layout.addLayout(form)
# Fix panel width and allow vertical expansion
self.control_panel.adjustSize()
p_hint = self.control_panel.sizeHint()
self.control_panel.setFixedWidth(p_hint.width())
self.control_panel.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
# Core Layout: image (expanding) | control panel (fixed)
self.core_layout.addWidget(self.image)
self.core_layout.addWidget(self.control_panel)
def _make_connections(self):
# Fetch initial state
self.on_live_view_enabled(True)
self.step_size.setValue(self.motor_control_2d.step_size)
# Make connections
self.live_preview_toggle.enabled.connect(self.on_live_view_enabled)
self.step_size.valueChanged.connect(lambda x: self.motor_control_2d.setProperty("step_size", x))
self.submit_button.clicked.connect(self.submit)
def _create_separator(self):
sep = QFrame(parent=self)
sep.setFrameShape(QFrame.HLine)
sep.setFrameShadow(QFrame.Sunken)
sep.setLineWidth(1)
return sep
def _init_gui_trigger(self):
self.dev.omny_xray_gui.read()
################################################################################
# Device Connection logic
################################################################################
def connect_motors(self):
""" Checks one of the possible motors for flomni, omny and lamni setup."""
possible_motors = ['osamroy', 'lsamrot', 'fsamroy']
for motor in possible_motors:
if motor in self.dev:
self.bec_dispatcher.connect_slot(self.on_tomo_angle_readback, MessageEndpoints.device_readback(motor))
logger.info(f"Succesfully connected to {motor}")
################################################################################
# Properties ported from the original OmnyAlignment, can be adjusted as needed
################################################################################
@SafeProperty(str)
def user_message(self):
return self.message_line_edit.text()
@user_message.setter
def user_message(self, message: str):
self.message_line_edit.setText(message)
@SafeProperty(str)
def sample_name(self):
return self.sample_name_line_edit.text()
@sample_name.setter
def sample_name(self, message: str):
self.sample_name_line_edit.setText(message)
@SafeProperty(bool)
def enable_move_buttons(self):
return self.motor_control_2d.isEnabled()
@enable_move_buttons.setter
def enable_move_buttons(self, enabled: bool):
self.motor_control_2d.setEnabled(enabled)
def active_roi(self) -> BaseROI | None:
"""Return the currently active ROI, or None if no ROI is active."""
return self.roi_manager.single_active_roi
################################################################################
# Slots ported from the original OmnyAlignment, can be adjusted as needed
################################################################################
@SafeSlot()
def get_roi_coordinates(self) -> dict | None:
"""Get the coordinates of the currently active ROI."""
roi = self.roi_manager.single_active_roi
if roi is None:
logger.warning("No active ROI")
return None
logger.info(f"Active ROI coordinates: {roi.get_coordinates()}")
return roi.get_coordinates()
@SafeSlot(bool)
def on_live_view_enabled(self, enabled: bool):
logger.info(f"Live view is enabled: {enabled}")
self.live_preview_toggle.blockSignals(True)
if enabled:
self.live_preview_toggle.checked = enabled
self.image.image(CAMERA)
self.live_preview_toggle.blockSignals(False)
return
self.image.disconnect_monitor(CAMERA)
self.live_preview_toggle.checked = enabled
self.live_preview_toggle.blockSignals(False)
@SafeSlot(bool, bool)
def on_motors_enable(self, x_enable: bool, y_enable: bool):
"""
Enable/Disable motor controls
Args:
x_enable(bool): enable x motor controls
y_enable(bool): enable y motor controls
"""
self.motor_control_2d.enable_controls_hor(x_enable)
self.motor_control_2d.enable_controls_ver(y_enable)
@SafeSlot(int)
def enable_submit_button(self, enable: int):
"""
Enable/disable submit button.
Args:
enable(int): -1 disable else enable
"""
if enable == -1:
self.submit_button.setEnabled(False)
else:
self.submit_button.setEnabled(True)
@SafeSlot(bool, bool)
def on_tomo_angle_readback(self, data: dict, meta: dict):
#TODO implement if needed
print(f"data: {data}")
print(f"meta: {meta}")
@SafeSlot(dict, dict)
def device_updates(self, data: dict, meta: dict):
"""
Slot to handle device updates from omny_xray_gui device.
Args:
data(dict): data from device
meta(dict): metadata from device
"""
signals = data.get('signals')
enable_live_preview = signals.get("omny_xray_gui_update_frame_acq").get('value')
enable_x_motor = signals.get("omny_xray_gui_enable_mv_x").get('value')
enable_y_motor = signals.get("omny_xray_gui_enable_mv_y").get('value')
self.on_live_view_enabled(bool(enable_live_preview))
self.on_motors_enable(bool(enable_x_motor), bool(enable_y_motor))
# Signals from epics gui device
# send message
user_message = signals.get("omny_xray_gui_send_message").get('value')
self.user_message = user_message
# sample name
sample_message = signals.get("omny_xray_gui_sample_name").get('value')
self.sample_name = sample_message
# enable frame acquisition
update_frame_acq = signals.get("omny_xray_gui_update_frame_acq").get('value')
self.on_live_view_enabled(bool(update_frame_acq))
# enable submit button
enable_submit_button = signals.get("omny_xray_gui_submit").get('value')
self.enable_submit_button(enable_submit_button)
@SafeSlot()
def submit(self):
"""Execute submit action by submit button."""
if self.roi_manager.single_active_roi is None:
logger.warning("No active ROI")
return
roi_coordinates = self.roi_manager.single_active_roi.get_coordinates()
roi_center_x = roi_coordinates['center_x']
roi_center_y = roi_coordinates['center_y']
# Case of rectangular ROI
if isinstance(self.roi_manager.single_active_roi, RectangularROI):
roi_width = roi_coordinates['width']
roi_height = roi_coordinates['height']
elif isinstance(self.roi_manager.single_active_roi, CircularROI):
roi_width = roi_coordinates['diameter']
roi_height = roi_coordinates['radius']
else:
logger.warning("Unsupported ROI type for submit action.")
return
print(f"current roi: x:{roi_center_x}, y:{roi_center_y}, w:{roi_width},h:{roi_height}") #TODO remove when will be not needed for debugging
# submit roi coordinates
step = int(self.dev.omny_xray_gui.step.read().get("omny_xray_gui_step").get('value'))
xval_x = getattr(self.dev.omny_xray_gui.xval_x, f"xval_x_{step}").set(roi_center_x)
xval_y = getattr(self.dev.omny_xray_gui.yval_y, f"yval_y_{step}").set(roi_center_y)
width_x = getattr(self.dev.omny_xray_gui.width_x, f"width_x_{step}").set(roi_width)
width_y = getattr(self.dev.omny_xray_gui.width_y, f"width_y_{step}").set(roi_height)
self.dev.omny_xray_gui.submit.set(1)
def cleanup(self):
"""Cleanup connections on widget close -> disconnect slots and stop live mode of camera."""
self.bec_dispatcher.disconnect_slot(self.device_updates, MessageEndpoints.device_readback("omny_xray_gui"))
getattr(self.dev,CAMERA[0]).live_mode = False
super().cleanup()
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
win = XRayEye()
win.resize(1000, 800)
win.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1 @@
{'files': ['x_ray_eye.py']}

View File

@@ -0,0 +1,57 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from bec_widgets.utils.bec_designer import designer_material_icon
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye import XRayEye
DOM_XML = """
<ui language='c++'>
<widget class='XRayEye' name='x_ray_eye'>
</widget>
</ui>
"""
class XRayEyePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = XRayEye(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(XRayEye.ICON_NAME)
def includeFile(self):
return "x_ray_eye"
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 "XRayEye"
def toolTip(self):
return "XRayEye"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,11 +0,0 @@
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()

View File

View File

@@ -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()

View File

@@ -27,20 +27,20 @@ mokev:
onFailure: buffer
readoutPriority: baseline
softwareTrigger: false
mcs:
description: Mcs scalar card for transmission readout
deviceClass: csaxs_bec.devices.epics.mcs_csaxs.MCScSAXS
deviceConfig:
prefix: 'X12SA-MCS:'
mcs_config:
num_lines: 1
deviceTags:
- cSAXS
- mcs
onFailure: buffer
enabled: true
readoutPriority: monitored
softwareTrigger: false
# mcs:
# description: Mcs scalar card for transmission readout
# deviceClass: csaxs_bec.devices.epics.mcs_csaxs.MCScSAXS
# deviceConfig:
# prefix: 'X12SA-MCS:'
# mcs_config:
# num_lines: 1
# deviceTags:
# - cSAXS
# - mcs
# onFailure: buffer
# enabled: true
# readoutPriority: monitored
# softwareTrigger: false
eiger9m:
description: Eiger9m HPC area detector 9M
deviceClass: csaxs_bec.devices.epics.eiger9m_csaxs.Eiger9McSAXS
@@ -53,89 +53,6 @@ eiger9m:
enabled: true
readoutPriority: async
softwareTrigger: false
ddg_detectors:
description: DelayGenerator for detector triggering
deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS
deviceConfig:
prefix: 'delaygen:DG1:'
ddg_config:
delay_burst: 40.e-3
delta_width: 0
additional_triggers: 0
polarity:
- 1 # T0 -> DDG MCS
- 0 # eiger
- 1 # falcon
- 1
- 1
amplitude: 4.5
offset: 0
thres_trig_level: 2.5
set_high_on_exposure: False
set_high_on_stage: False
deviceTags:
- cSAXS
- ddg_detectors
onFailure: buffer
enabled: true
readoutPriority: async
softwareTrigger: false
ddg_mcs:
description: DelayGenerator for mcs triggering
deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS
deviceConfig:
prefix: 'delaygen:DG2:'
ddg_config:
delay_burst: 0
delta_width: 0
additional_triggers: 1
polarity:
- 1
- 0
- 1
- 1
- 1
amplitude: 4.5
offset: 0
thres_trig_level: 2.5
set_high_on_exposure: False
set_high_on_stage: False
set_trigger_source: EXT_RISING_EDGE
trigger_width: 3.e-3
deviceTags:
- cSAXS
- ddg_mcs
onFailure: buffer
enabled: true
readoutPriority: async
softwareTrigger: false
ddg_fsh:
description: DelayGenerator for fast shutter control
deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS
deviceConfig:
prefix: 'delaygen:DG3:'
ddg_config:
delay_burst: 0
delta_width: 80.e-3
additional_triggers: 0
polarity:
- 1
- 1
- 1
- 1
- 1
amplitude: 4.5
offset: 0
thres_trig_level: 2.5
set_high_on_exposure: True
set_high_on_stage: False
deviceTags:
- cSAXS
- ddg_fsh
onFailure: buffer
enabled: true
readoutPriority: async
softwareTrigger: false
falcon:
description: Falcon detector x-ray fluoresence
deviceClass: csaxs_bec.devices.epics.falcon_csaxs.FalconcSAXS
@@ -198,7 +115,7 @@ samy:
softwareTrigger: false
micfoc:
description: Focusing motor of Microscope stage
deviceClass: ophyd_devices.devices.EpicsMotorEx
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES2-ES06
motor_resolution: 0.00125
@@ -216,7 +133,7 @@ micfoc:
softwareTrigger: false
owis_samx:
description: Owis motor stage samx
deviceClass: ophyd_devices.devices.EpicsMotorEx
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES2-ES01
motor_resolution: 0.00125
@@ -234,7 +151,7 @@ owis_samx:
softwareTrigger: false
owis_samy:
description: Owis motor stage samx
deviceClass: ophyd_devices.devices.EpicsMotorEx
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES2-ES02
motor_resolution: 0.00125
@@ -252,7 +169,7 @@ owis_samy:
softwareTrigger: false
rotx:
description: Rotation stage rotx
deviceClass: ophyd_devices.devices.EpicsMotorEx
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES2-ES05
motor_resolution: 0.0025
@@ -273,7 +190,7 @@ rotx:
softwareTrigger: false
roty:
description: Rotation stage rotx
deviceClass: ophyd_devices.devices.EpicsMotorEx
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES2-ES04
motor_resolution: 0.0025

View File

@@ -0,0 +1,55 @@
ddg1:
description: Main delay Generator for triggering
deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DDG1
enabled: true
deviceConfig:
prefix: 'X12SA-CPCL-DDG1:'
onFailure: raise
readOnly: false
readoutPriority: baseline
softwareTrigger: true
ddg2:
description: Detector delay Generator for trigger burst
deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DDG2
enabled: true
deviceConfig:
prefix: 'X12SA-CPCL-DDG2:'
onFailure: raise
readOnly: false
readoutPriority: baseline
softwareTrigger: false
mcs:
description: Mcs scalar card for transmission readout
deviceClass: csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs.MCSCardCSAXS
deviceConfig:
prefix: 'X12SA-MCS:'
onFailure: raise
enabled: true
readoutPriority: monitored
softwareTrigger: false
ids_cam:
description: IDS camera for live image acquisition
deviceClass: csaxs_bec.devices.ids_cameras.IDSCamera
deviceConfig:
camera_id: 201
bits_per_pixel: 24
m_n_colormode: 1
live_mode: True
onFailure: raise
enabled: true
readoutPriority: async
softwareTrigger: True
eiger_1_5:
description: Eiger 1.5M in-vacuum detector
deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M
deviceConfig:
detector_distance: 100
beam_center: [0, 0]
onFailure: raise
enabled: true
readoutPriority: async
softwareTrigger: False

View File

@@ -1,50 +0,0 @@
ddg_detectors:
description: DelayGenerator for detector triggering
deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS
deviceConfig:
prefix: 'X12SA-CPCL-DDG3:'
ddg_config:
delay_burst: 40.e-3
delta_width: 0
additional_triggers: 0
polarity:
- 1 # T0 -> DDG MCS
- 0 # eiger
- 1 # falcon
- 1
- 1
amplitude: 4.5
offset: 0
thres_trig_level: 2.5
set_high_on_exposure: False
set_high_on_stage: False
deviceTags:
- cSAXS
- ddg_detectors
onFailure: buffer
enabled: true
readoutPriority: async
softwareTrigger: True
bpm4i:
readoutPriority: monitored
deviceClass: ophyd_devices.SimMonitor
deviceConfig:
deviceTags:
- beamline
enabled: true
readOnly: false
samx:
readoutPriority: baseline
deviceClass: ophyd_devices.SimPositioner
deviceConfig:
delay: 1
limits:
- -50
- 50
tolerance: 0.01
update_frequency: 400
deviceTags:
- user motors
enabled: true
readOnly: false

View File

@@ -0,0 +1,8 @@
optics:
- !include ./optics_hutch.yaml
frontend:
- !include ./frontend.yaml
endstation:
- !include ./endstation.yaml

View File

@@ -213,6 +213,8 @@ ftransy:
onFailure: buffer
readOnly: false
readoutPriority: baseline
userParameter:
sensor_voltage: -2.4
ftransz:
description: Sample transer Z
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
@@ -333,8 +335,8 @@ rtx:
readOnly: false
readoutPriority: on_request
userParameter:
low_signal: 11000
min_signal: 10000
low_signal: 10000
min_signal: 9000
rt_pid_voltage: -0.06219
rty:
description: flomni rt
@@ -362,3 +364,105 @@ rtz:
onFailure: buffer
readOnly: false
readoutPriority: on_request
############################################################
####################### Cameras ############################
############################################################
cam_flomni_gripper:
description: Camera sample changer
deviceClass: csaxs_bec.devices.omny.webcam_viewer.WebcamViewer
deviceConfig:
url: http://flomnicamserver:5000/video_high
num_rotation_90: 3
transpose: false
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: on_request
cam_flomni_overview:
description: Camera flomni overview
deviceClass: csaxs_bec.devices.omny.webcam_viewer.WebcamViewer
deviceConfig:
url: http://flomnicamserver:5001/video_high
num_rotation_90: 3
transpose: false
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: on_request
cam_xeye:
description: Camera flOMNI Xray eye ID1
deviceClass: csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera
deviceConfig:
camera_id: 1
bits_per_pixel: 24
num_rotation_90: 3
transpose: false
force_monochrome: true
m_n_colormode: 1
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: async
cam_ids_rgb:
description: Camera flOMNI Xray eye ID203
deviceClass: csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera
deviceConfig:
camera_id: 203
bits_per_pixel: 24
num_rotation_90: 3
transpose: false
force_monochrome: true
m_n_colormode: 1
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: async
# ############################################################
# ################### flOMNI temperatures ####################
# ############################################################
flomni_temphum:
description: flOMNI Temperatures and humidity
deviceClass: csaxs_bec.devices.omny.flomni_temp_and_humidity.FlomniTempHum
deviceConfig: {}
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
# ############################################################
# ########## OMNY / flOMNI / LamNI fast shutter ##############
# ############################################################
omnyfsh:
description: omnyfsh connects to read fast shutter at X12 if in that network
deviceClass: csaxs_bec.devices.omny.shutter.OMNYFastEpicsShutter
deviceConfig: {}
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
############################################################
#################### GUI Signals ###########################
############################################################
omny_xray_gui:
description: Gui Epics signals
deviceClass: csaxs_bec.devices.omny.xray_epics_gui.OMNYXRayEpicsGUI
deviceConfig: {}
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: on_request
calculated_signal:
description: Calculated signal from alignment for fit
deviceClass: ophyd_devices.ComputedSignal
deviceConfig:
compute_method: "def just_rand():\n return 42"
enabled: true
readOnly: false
readoutPriority: baseline

View File

@@ -0,0 +1,215 @@
idgap:
description: 'Motor to control the IDGap of X12SA'
deviceClass: ophyd_devices.devices.undulator.UndulatorGap
deviceConfig:
prefix: 'X12SA-UIND:'
onFailure: raise # Consider changing to buffer
enabled: true
readoutPriority: baseline
readOnly: false # put to false if you like to move it
softwareTrigger: false
xbpm1x:
description: 'X-ray BPM1 in frontend translation x'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-XBPM1:TRX'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- frontend
xbpm1y:
description: 'X-ray BPM1 in frontend translation y'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-XBPM1:TRY'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- frontend
sl1xr:
description: 'slit 1 (frontend) x ring'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-SL1:TRXR'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- frontend
sl1xw:
description: 'slit 1 (frontend) x wall'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-SL1:TRXW'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- frontend
sl1yb:
description: 'slit 1 (frontend) y bottom'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-SL1:TRYB'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- frontend
sl1yt:
description: 'slit 1 (frontend) y top'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-SL1:TRYT'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- frontend
sl1xc:
description: 'slit 1 (frontend) x center'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-SL1:CENTERX'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- frontend
sl1xs:
description: 'slit 1 (frontend) x size'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-SL1:SIZEX'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- frontend
sl1yc:
description: 'slit 1 (frontend) y center'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-SL1:CENTERY'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- frontend
sl1ys:
description: 'slit 1 (frontend) y size'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-SL1:SIZEY'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- frontend
#####################################
#### XBPM ###########################
#####################################
# Note: The following device may not be relevant anymore
# and can be fully replaced by the combined device "xbpm1", see below
xbpm1c1:
description: 'XBPM1 (frontend) current 1'
deviceClass: ophyd.EpicsSignalRO
deviceConfig:
read_pv: 'X12SA-FE-XBPM1:Current1:MeanValue_RBV'
onFailure: raise
enabled: true
readoutPriority: monitored
readOnly: true
softwareTrigger: false
xbpm1c2:
description: 'XBPM1 (frontend) current 2'
deviceClass: ophyd.EpicsSignalRO
deviceConfig:
read_pv: 'X12SA-FE-XBPM1:Current2:MeanValue_RBV'
onFailure: raise
enabled: true
readoutPriority: monitored
readOnly: true
softwareTrigger: false
xbpm1c3:
description: 'XBPM1 (frontend) current 3'
deviceClass: ophyd.EpicsSignalRO
deviceConfig:
read_pv: 'X12SA-FE-XBPM1:Current3:MeanValue_RBV'
onFailure: raise
enabled: true
readoutPriority: monitored
readOnly: true
softwareTrigger: false
xbpm1c4:
description: 'XBPM1 (frontend) current 4'
deviceClass: ophyd.EpicsSignalRO
deviceConfig:
read_pv: 'X12SA-FE-XBPM1:Current4:MeanValue_RBV'
onFailure: raise
enabled: true
readoutPriority: monitored
readOnly: true
softwareTrigger: false
############################################
######### End of xbpm sub devices ##########
############################################
xbpm1:
description: 'XBPM1 (frontend)'
deviceClass: csaxs_bec.devices.epics.xbpms.BPMDevice
deviceConfig:
prefix: 'X12SA-FE-XBPM1'
onFailure: raise
enabled: true
readoutPriority: monitored
readOnly: true
softwareTrigger: false

View File

@@ -0,0 +1,162 @@
dmmroty:
description: 'Double Multilayer Monochromator rotation Y'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-OP-DMM1:ROTY'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- optics
dmmx:
description: 'Double Multilayer Monochromator, translation X'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-OP-DMM1:TRX'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- optics
dmmy:
description: 'Double Multilayer Monochromator, translation Y'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-OP-DMM1:TRY'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- optics
ccmroty:
description: 'Channel-cut Monochromator rotation Y'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-OP-CCM1:ROTY'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- optics
ccmx:
description: 'Channel-cut Monochromator, translation X'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-OP-CCM1:TRX'
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- cSAXS
- optics
xbpm2x:
description: X-ray beam position monitor 1 in OPbox
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
deviceConfig:
axis_Id: A
host: x12sa-eb-smaract-mcs-03.psi.ch
limits:
- -200
- 200
port: 5000
sign: 1
# precision: 3
# tolerance: 0.005
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
xbpm2y:
description: X-ray beam position monitor 1 in OPbox
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
deviceConfig:
axis_Id: B
host: x12sa-eb-smaract-mcs-03.psi.ch
limits:
- -200
- 200
port: 5000
sign: 1
# precision: 3
# tolerance: 0.005
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
cu_foilx:
description: Cu foil in OPbox
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
deviceConfig:
axis_Id: C
host: x12sa-eb-smaract-mcs-03.psi.ch
limits:
- -200
- 200
port: 5000
sign: 1
# precision: 3
# tolerance: 0.005
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
scinx:
description: scintillator in OPbox
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
deviceConfig:
axis_Id: D
host: x12sa-eb-smaract-mcs-03.psi.ch
limits:
- -200
- 200
port: 5000
sign: 1
# precision: 3
# tolerance: 0.005
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
# dmm1_trx_readback_example: # This is the same template as for i.e. bpm4i
# description: 'This is an example of a read-only Epics signal'
# deviceClass: ophyd.EpicsSignalRO
# deviceConfig:
# read_pv: 'X12SA-OP-DMM1:TRX.RBV'
# onFailure: raise
# enabled: true
# readoutPriority: monitored
# readOnly: true
# softwareTrigger: false
# my_settable_signal:
# description: 'This is an example of a settable Epics signal'
# deviceClass: ophyd.EpicsSignal
# deviceConfig:
# read_pv: 'X07MA-FE-DSAPER'
# onFailure: retry
# enabled: true
# readoutPriority: baseline
# readOnly: false
# softwareTrigger: false

View File

@@ -0,0 +1,96 @@
samx:
description: Owis motor stage samx
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES2-ES01
motor_resolution: 0.00125
base_velocity: 0.0625
velocity: 10
backlash_distance: 0.125
acceleration: 0.2
user_offset_dir: 1
deviceTags:
- cSAXS
- owis_samx
onFailure: buffer
enabled: true
readoutPriority: baseline
softwareTrigger: false
samy:
description: Owis motor stage samy
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES2-ES02
motor_resolution: 0.00125
base_velocity: 0.0625
velocity: 10
backlash_distance: 0.125
acceleration: 0.2
user_offset_dir: 0
deviceTags:
- cSAXS
- owis_samx
onFailure: buffer
enabled: true
readoutPriority: baseline
softwareTrigger: false
rotx:
description: Rotation stage rotx
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES2-ES03
motor_resolution: 0.0025
base_velocity: 0.5
velocity: 7.5
backlash_distance: 0.25
acceleration: 0.2
user_offset_dir: 1
limits:
- -0.1
- 0.1
deviceTags:
- cSAXS
- rotx
onFailure: buffer
enabled: true
readoutPriority: baseline
softwareTrigger: false
roty:
description: Rotation stage roty
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES2-ES04
motor_resolution: 0.0025
base_velocity: 0.5
velocity: 7.5
backlash_distance: 0.25
acceleration: 0.2
user_offset_dir: 0
limits:
- -0.1
- 0.1
deviceTags:
- cSAXS
- roty
onFailure: buffer
enabled: true
readoutPriority: baseline
softwareTrigger: false
micfoc:
description: Focusing motor of Microscope stage
deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME
deviceConfig:
prefix: X12SA-ES2-ES05
motor_resolution: 0.00125
base_velocity: 0.25
velocity: 2.5
backlash_distance: 0.125
acceleration: 0.4
user_offset_dir: 0
deviceTags:
- cSAXS
- micfoc
onFailure: buffer
enabled: true
readoutPriority: baseline
softwareTrigger: false

View File

@@ -1,274 +0,0 @@
from bec_lib import bec_logger
from ophyd import Component, DeviceStatus, Kind
from ophyd_devices.devices.delay_generator_645 import DelayGenerator, TriggerSource
from ophyd_devices.interfaces.base_classes.bec_device_base import BECDeviceBase, CustomPrepare
from ophyd_devices.sim.sim_signals import SetableSignal
from ophyd_devices.utils import bec_utils
logger = bec_logger.logger
class DelayGeneratorcSAXSError(Exception):
"""Exception raised for errors."""
class DDGSetup(CustomPrepare["DelayGeneratorcSAXS"]):
"""
Custom Prepare class with hooks for beamline specific logic for the DG645 at CSAXS
"""
def on_wait_for_connection(self) -> None:
"""Init default parameter after the all signals are connected"""
for ii, channel in enumerate(self.parent.all_channels):
self.parent.set_channels("polarity", self.parent.polarity.get()[ii], [channel])
self.parent.set_channels("amplitude", self.parent.amplitude.get())
self.parent.set_channels("offset", self.parent.offset.get())
# Setup reference
self.parent.set_channels(
"reference", 0, [f"channel{pair}.ch1" for pair in self.parent.all_delay_pairs]
)
self.parent.set_channels(
"reference", 0, [f"channel{pair}.ch2" for pair in self.parent.all_delay_pairs]
)
self.parent.set_trigger(getattr(TriggerSource, self.parent.set_trigger_source.get()))
# Set threshold level for ext. pulses
self.parent.level.put(self.parent.thres_trig_level.get())
def on_stage(self) -> None:
"Hook execute before the scan starts"
if self.parent.scaninfo.scan_type == "step":
exp_time = self.parent.scaninfo.exp_time
delay = 0
self.parent.burst_disable()
self.parent.set_trigger(TriggerSource.SINGLE_SHOT)
self.parent.set_channels(signal="width", value=exp_time)
self.parent.set_channels(signal="delay", value=delay)
return
scan_name = self.parent.scaninfo.scan_msg.content["info"].get("scan_name", "")
if scan_name == "jjf_test":
# TODO implement the logic for JJF triggering
exp_time = 480e-6 # self.parent.scaninfo.exp_time
readout = 20e-6 # self.parent.scaninfo.readout_time
total_exposure = exp_time + readout
num_burst_cycle = self.parent.scaninfo.scan_msg.content["info"]["kwargs"]["num_points"]
num_burst_cycle = int(num_burst_cycle * self.parent.scaninfo.exp_time / total_exposure)
delay = 0
delay_burst = self.parent.delay_burst.get()
self.parent.set_trigger(trigger_source=TriggerSource.SINGLE_SHOT)
self.parent.set_channels(signal="width", value=exp_time)
self.parent.set_channels(signal="delay", value=delay)
self.parent.burst_enable(
count=num_burst_cycle, delay=delay_burst, period=total_exposure, config="first"
)
logger.info(
f"{self.parent.name}: On stage with n_burst: {num_burst_cycle} and total_exp {total_exposure}"
)
def on_trigger(self) -> DeviceStatus:
"""Method to be executed upon trigger"""
if self.parent.scaninfo.scan_type == "step":
self.parent.trigger_shot.put(1)
return
scan_name = self.parent.scaninfo.scan_msg.content["info"].get("scan_name", "")
if scan_name == "jjf_test":
exp_time = 480e-6 # self.parent.scaninfo.exp_time
readout = 20e-6 # self.parent.scaninfo.readout_time
total_exposure = exp_time + readout
num_burst_cycle = self.parent.scaninfo.scan_msg.content["info"]["kwargs"]["num_points"]
num_burst_cycle = int(num_burst_cycle * self.parent.scaninfo.exp_time / total_exposure)
# Start trigger cycle
self.parent.trigger_burst_readout.put(1)
# Create status object that will wait for the end of the burst cycle
status = self.wait_with_status(
signal_conditions=[(self.parent.burst_cycle_finished, 1)],
timeout=num_burst_cycle * total_exposure + 1, # add 1s to be sure
check_stopped=True,
exception_on_timeout=DelayGeneratorcSAXSError(
f"{self.parent.name} run into timeout in complete call."
),
)
logger.info(f"Return status {self.parent.name}")
return status
def on_complete(self) -> DeviceStatus:
pass
def on_pre_scan(self) -> None:
"""
Method called by pre_scan hook in parent class.
Executes trigger if premove_trigger is Trus.
"""
if self.parent.premove_trigger.get() is True:
self.parent.trigger_shot.put(1)
class DelayGeneratorcSAXS(BECDeviceBase, DelayGenerator):
"""
DG645 delay generator at cSAXS (multiple can be in use depending on the setup)
Default values for setting up DDG.
Note: checks of set calues are not (only partially) included, check manual for details on possible settings.
https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf
- delay_burst : (float >=0) Delay between trigger and first pulse in burst mode
- delta_width : (float >= 0) Add width to fast shutter signal to make sure its open during acquisition
- additional_triggers : (int) add additional triggers to burst mode (mcs card needs +1 triggers per line)
- polarity : (list of 0/1) polarity for different channels
- amplitude : (float) amplitude voltage of TTLs
- offset : (float) offset for ampltitude
- thres_trig_level : (float) threshold of trigger amplitude
Custom signals for logic in different DDGs during scans (for custom_prepare.prepare_ddg):
- set_high_on_exposure : (bool): if True, then TTL signal should go high during the full acquisition time of a scan.
# TODO trigger_width and fixed_ttl could be combined into single list.
- fixed_ttl_width : (list of either 1 or 0), one for each channel.
- trigger_width : (float) if fixed_ttl_width is True, then the width of the TTL pulse is set to this value.
- set_trigger_source : (TriggerSource) specifies the default trigger source for the DDG.
- premove_trigger : (bool) if True, then a trigger should be executed before the scan starts (to be implemented in on_pre_scan).
- set_high_on_stage : (bool) if True, then TTL signal should go high already on stage.
"""
custom_prepare_cls = DDGSetup
# Custom signals passed on during the init procedure via BEC
# TODO review whether those should remain here like that
delay_burst = Component(
bec_utils.ConfigSignal, name="delay_burst", kind="config", config_storage_name="ddg_config"
)
delta_width = Component(
bec_utils.ConfigSignal, name="delta_width", kind="config", config_storage_name="ddg_config"
)
additional_triggers = Component(
bec_utils.ConfigSignal,
name="additional_triggers",
kind="config",
config_storage_name="ddg_config",
)
polarity = Component(
bec_utils.ConfigSignal, name="polarity", kind="config", config_storage_name="ddg_config"
)
fixed_ttl_width = Component(
bec_utils.ConfigSignal,
name="fixed_ttl_width",
kind="config",
config_storage_name="ddg_config",
)
amplitude = Component(
bec_utils.ConfigSignal, name="amplitude", kind="config", config_storage_name="ddg_config"
)
offset = Component(
bec_utils.ConfigSignal, name="offset", kind="config", config_storage_name="ddg_config"
)
thres_trig_level = Component(
bec_utils.ConfigSignal,
name="thres_trig_level",
kind="config",
config_storage_name="ddg_config",
)
set_high_on_exposure = Component(
bec_utils.ConfigSignal,
name="set_high_on_exposure",
kind="config",
config_storage_name="ddg_config",
)
set_high_on_stage = Component(
bec_utils.ConfigSignal,
name="set_high_on_stage",
kind="config",
config_storage_name="ddg_config",
)
set_trigger_source = Component(
bec_utils.ConfigSignal,
name="set_trigger_source",
kind="config",
config_storage_name="ddg_config",
)
trigger_width = Component(
bec_utils.ConfigSignal,
name="trigger_width",
kind="config",
config_storage_name="ddg_config",
)
premove_trigger = Component(
bec_utils.ConfigSignal,
name="premove_trigger",
kind="config",
config_storage_name="ddg_config",
)
def __init__(
self,
name: str,
prefix: str = "",
kind: Kind = None,
ddg_config: dict = None,
parent=None,
device_manager=None,
**kwargs,
):
"""
Args:
prefix (str, optional): Prefix of the device. Defaults to "".
name (str): Name of the device.
kind (str, optional): Kind of the device. Defaults to None.
read_attrs (list, optional): List of attributes to read. Defaults to None.
configuration_attrs (list, optional): List of attributes to configure. Defaults to None.
parent (Device, optional): Parent device. Defaults to None.
device_manager (DeviceManagerBase, optional): DeviceManagerBase object. Defaults to None.
sim_mode (bool, optional): Simulation mode flag. Defaults to False.
ddg_config (dict, optional): Dictionary of ddg_config signals. Defaults to None.
"""
# Default values for ddg_config signals
self.ddg_config = {
# Setup default values
f"{name}_delay_burst": 0,
f"{name}_delta_width": 0,
f"{name}_additional_triggers": 0,
f"{name}_polarity": [1, 1, 1, 1, 1],
f"{name}_amplitude": 4.5,
f"{name}_offset": 0,
f"{name}_thres_trig_level": 2.5,
# Values for different behaviour during scans
f"{name}_fixed_ttl_width": [0, 0, 0, 0, 0],
f"{name}_trigger_width": None,
f"{name}_set_high_on_exposure": False,
f"{name}_set_high_on_stage": False,
f"{name}_set_trigger_source": "SINGLE_SHOT",
f"{name}_premove_trigger": False,
}
if ddg_config is not None:
# pylint: disable=expression-not-assigned
[self.ddg_config.update({f"{name}_{key}": value}) for key, value in ddg_config.items()]
super().__init__(
prefix=prefix,
name=name,
kind=kind,
parent=parent,
device_manager=device_manager,
**kwargs,
)
# if __name__ == "__main__":
# dgen = DelayGeneratorcSAXS("X12SA-CPCL-DDG3:", name="ddg3")

View File

@@ -0,0 +1,58 @@
# Delay Generator implementation at the CSAXS beamline
This module provides an ophyd device implementation for the Stanford Research Systems Delay Generator DDG645, used at the cSAXS beamline as a master timing source for detector triggering and other beamline devices. Detailed information about the DDG manual can be found here:
https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf.
The implementation is based on a community EPICS driver (https://github.com/epics-modules/delaygen?tab=readme-ov-file).
**EPICS Interface**
At the cSAXS beamline, the DDG panel is avaiable via caqtdm on the beamline consoles.
``` bash
caqtdm -noMsg -attach -macro P=X12SA-CPCL-DDG,R=1: srsDG645.ui
```
with R=1,2,3,4,5 for 5 different DDG units installed at CSAXS.
# Ophyd Device integration at cSAXS
For cSAXS, a custom ophyd device class implementation of the DDG is provided [here](./delay_generator_csaxs.py). This class provides a basic interface to the DDG PVs. The interface provides channels 'A', B', 'C', ... with setpoint, readback and references, as well as high level parameters such as *width* and *delay*. Please check the source code of the class for more details of the implementation.
In addition, the class provides a set of utility methods to configure sets of channel pairs 'AB', 'CD', ... as commonly needed in operation at the beamline. At the cSAXS beamline, a single DDG device is used as a master timing source for other devices. The general scheme is described in a [PDF document here](./trigger_scheme_ddg1_ddg2.pdf). Below is a description of the configuration of the two DDG units used at cSAXS for detector triggering and beamline shutter control.
## Master card: DDG1 (X12SA-CPCL-DDG1)
The master [delay generator DDG1](./ddg_1.py) is configured to provide the following signals:
**Connection Scheme**:
- EXT/EN: May be connected to external devices, e.g. SGalil motion controller for fly scans.
- Operation Mode: Burst mode, but with single burst (burst count = 1). This is for practical reasons as it allows
to interrupt and ongoing sequence if needed.
- Software Trigger: Controlled through BEC.
- State Control: BEC checks the *state* of this DDG to wait for the completion of a timing sequence.
**Delay Pairs**:
- DelayPair 'AB': Provides the external enable (EXT/EN) signal to the second DDG (R=2).
- DelayPair 'CD': Controls the beamline shutter.
- DelayPair 'EF': Generates pulses for the MCS card, combined with the detector pulse train via an OR gate. This ensures the MCS card receives an additional pulse required for proper operation.
**Delay Channels**:
- a = t0 + 2ms (2ms delay to allow the shutter to open)
- b = a + 1us (short pulse)
- c = t0
- d = a + exp_time * burst_count + 1ms (to allow the shutter to close)
- e = d
- f = e + 1us (short pulse to OR gate for MCS triggering)
## Detector card: DDG2 (X12SA-CPCL-DDG2)
The second [delay generator DDG2](./ddg_2.py) is configured to provide the following signals:
**Connection Scheme**:
- EXT/EN: Connected to the DelayPair AB of the master DDG (R=1).
- Operation Mode: Burst mode: The *burst count* is set to the number of frames per trigger. The *burst delay* is set to 0, and the *burst period* is set to the exposure time.
- Software Trigger: Irrelevant, as the device is externally triggered by DDG1.
**Delay Pairs**:
- DelayPair 'AB': Provides the trigger signal to the detector.
**Delay Channels**:
- a = t0
- b = a + (exp_time - READOUT_TIMES)

View File

@@ -0,0 +1,14 @@
from .ddg_1 import DDG1
from .ddg_2 import DDG2
from .delay_generator_csaxs import (
BURSTCONFIG,
CHANNELREFERENCE,
OUTPUTPOLARITY,
STATUSBITS,
TRIGGERINHIBIT,
TRIGGERSOURCE,
AllChannelNames,
ChannelConfig,
DelayChannelNames,
)
from .error_registry import ERROR_CODES

View File

@@ -0,0 +1,496 @@
"""
DDG1 delay generator
This module implements the DDG1 delay generator logic for the CSAXS beamline.
The attached PDF trigger_scheme_ddg1_ddg2.pdf provides a more detailed overview of
the trigger scheme. If the logic changes in the future, it is highly recommended to
update the PDF accordingly.
The DDG1 is the main trigger delay generator for the CSAXS beamline. It will
receive either a soft trigger from BEC (depending on the scan type) or a hardware trigger
from a beamline device (e.g. the Galil stages). It is responsible for opening the shutter
and sending a trigger to the Delay Generator CSAXS (DDG2), which in turn will
send the trigger to the detectors. DDG1 will not be witout burst mode, but rather in standard
mode creating delays for the channels ab, cd, ef, gh.
A brief summary of the DDG1 logic:
DELAY PAIRS:
- DelayPair ab is connected to the EXT/EN of DDG2.
- DelayPair cd is connected to the SHUTTER.
- DelayPair ef is connected to an OR gate together with the detector
PULSE train for the MCS card. The MCS card needs one extra pulse to forward points.
DELAY CHANNELS:
- a = t0 + 2ms (2ms delay to allow the shutter to open)
- b = a + 1us (short pulse)
- c = t0
- d = a + exp_time * burst_count + 1ms (to allow the shutter to close)
- e = d
- f = e + 1us (short pulse to OR gate for MCS triggering)
"""
from __future__ import annotations
import threading
import time
import traceback
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from ophyd_devices import CompareStatus, DeviceStatus, TransitionStatus
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import (
CHANNELREFERENCE,
OUTPUTPOLARITY,
PROC_EVENT_MODE,
STATUSBITS,
TRIGGERSOURCE,
AllChannelNames,
ChannelConfig,
DelayGeneratorCSAXS,
LiteralChannels,
StatusBitsCompareStatus,
)
from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import ACQUIRING
if TYPE_CHECKING: # pragma: no cover
from bec_lib.devicemanager import DeviceManagerBase, ScanInfo
from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import MCSCardCSAXS
logger = bec_logger.logger
########################
## DEFAULT SETTINGS ####
########################
# NOTE Default channel configuration for all channels of the DDG1 delay generator
# This can be adapted as needed, or fine-tuned per channel. On every reload of the
# device configuration in BEC, these values will be set into the DDG1 device.
_DEFAULT_CHANNEL_CONFIG: ChannelConfig = {
"amplitude": 5.0,
"offset": 0.0,
"polarity": OUTPUTPOLARITY.POSITIVE,
"mode": "ttl",
}
# NOTE Here you can adapt the default IO configuration for all channels of the DDG1
# Currently, all channels are set to the same default configuration `_DEFAULT_CHANNEL_CONFIG`.
DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = {
"t0": _DEFAULT_CHANNEL_CONFIG,
"ab": _DEFAULT_CHANNEL_CONFIG,
"cd": _DEFAULT_CHANNEL_CONFIG,
"ef": _DEFAULT_CHANNEL_CONFIG,
"gh": _DEFAULT_CHANNEL_CONFIG,
}
DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.SINGLE_SHOT
# NOTE Default readout times for each channel, can be adapted as needed.
# These values are relevant to calculate proper widths of the timing signals.
# They also define a minimum exposure time that can be used as they are subtracted
# as dead times from the exposure time.
DEFAULT_READOUT_TIMES = {"ab": 2e-4, "cd": 2e-4, "ef": 2e-4, "gh": 2e-4} # 0.2 ms 5kHz
# NOTE Default channel references for each channel of the DDG1 delay generator.
# This needs to be carefully adjusted to match the envisioned trigger scheme.
# If the trigger scheme changes, adapt the values here together with the README and
# PDF `trigger_scheme_ddg1_ddg2.pdf`.
DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [
("A", CHANNELREFERENCE.T0), # T0 + 2ms delay
("B", CHANNELREFERENCE.A),
("C", CHANNELREFERENCE.T0), # T0
("D", CHANNELREFERENCE.C),
("E", CHANNELREFERENCE.D), # D One extra pulse once shutter closes for MCS
("F", CHANNELREFERENCE.E), # E + 1mu s
("G", CHANNELREFERENCE.T0),
("H", CHANNELREFERENCE.G),
]
###############################
## DDG1 IMPLEMENTATION ########
###############################
class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
"""
Implementation of the DelayGenerator DDG1 for the cSAXS beamline. It is the main trigger
source for the cSAXS beamline, and will be triggered by BEC through a software trigger or
by a hardware trigger from a beamline device (e.g. Galil stages). Specific implementation
of the cabling logic expected for this device are described in the module README, the attached
PDF 'trigger_scheme_ddg1_ddg2.pdf' and the module docstring.
The IOC prefix is 'X12SA-CPCL-DDG1:'.
Args:
name (str): Name of the device.
prefix (str, optional): EPICS prefix for the device. Defaults to ''.
scan_info (ScanInfo | None, optional): Scan info object. Defaults to None.
device_manager (DeviceManagerBase | None, optional): Device manager. Defaults to None.
"""
def __init__(
self,
name: str,
prefix: str = "",
scan_info: ScanInfo | None = None,
device_manager: DeviceManagerBase | None = None,
**kwargs,
):
super().__init__(
name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs
)
self.device_manager = device_manager
self._poll_thread = threading.Thread(target=self._poll_event_status, daemon=True)
self._poll_thread_run_event = threading.Event()
self._poll_thread_poll_loop_done = threading.Event()
self._poll_thread_kill_event = threading.Event()
self._poll_thread.start()
# pylint: disable=attribute-defined-outside-init
def on_connected(self) -> None:
"""
This method is called after the device is initialized and all signals are connected. This happens
when a device configuration is loaded in BEC.
It sets the default values for this device - intended to overwrite everything to a usable default state.
For this purpose, we use the DEFAULT SETTINGS defined at the top of this module.
To ensure that this process is robust, we follow these steps:
- First, we stop any ongoing burst mode operation.
- Then, we set the DEFAULT_IO_CONFIG for each channel, the trigger source to DEFAULT_TRIGGER_SOURCE,
and the channel references to DEFAULT_REFERENCES.
- We set the state proc_status to be event based. This triggers readouts of the EventStatusLI bit
based on events. This was empirically found to be a stable solution in combination with the poll
loop of the state.
- Finally, we set the burst delay to 0, to set it to be of no delay.
"""
# NOTE First we make sure that there is nothing running on the DDG. This seems to
# help to tackle that the DDG occasionally freezes during the first scan
# after reconnecting to it. Do not remove.
self.stop_ddg()
# NOTE Setting DEFAULT configurations for IO config, trigger config and references.
# The three dictionaries above 'DEFAULT_IO_CONFIG', 'DEFAULT_TRIGGER_SOURCE' and
# 'DEFAULT_REFERNCES' should be used to adapt configurations if needed.
for channel, config in DEFAULT_IO_CONFIG.items():
self.set_io_values(channel, **config)
self.set_trigger(DEFAULT_TRIGGER_SOURCE)
self.set_references_for_channels(DEFAULT_REFERENCES)
# NOTE Set state proc_status to be event based. This triggers readouts of the EventStatusLI bit
# based on events. This was empirically found to be a stable solution in combination with the poll
# loop of the state.
self.state.proc_status_mode.put(PROC_EVENT_MODE.EVENT)
# NOTE Burst delay should be set to 0, don't remove as this will not be checked
# Also set the burst count to 1 to only have a single pulse for DDG1.
self.burst_delay.put(0)
self.burst_count.put(1)
def on_stage(self) -> None:
"""
This method is called in preparation for a scan. All information about the upcoming
scan is available in self.scan_info.msg at this point. We use this information to
configure the DDG1 for the upcoming scan.
The DDG is operated in burst mode for the scan, but with only a single burst pulse.
THe length of the pulse is set to the expected exposure time for a single trigger,
which includes any burst acquisitions if frames_per_trigger > 1.
The logic is as follows:
- We check if any default burst parameters need to be set, and set them if needed.
- We calculate the burst pulse width based on the exposure time and frames_per_trigger.
- We set the burst_period and the shutter signal (delay pairs cd) to be
exposure_time * frames_per_trigger + 3ms (2ms for shutter to open, 1ms to close).
- We set the delay pairs ab to be 2ms delayed (to allow the shutter to open) with a width of 1us to trigger DDG2.
- We set the delay pairs ef to be triggered after the shutter closes with a width of 1us to trigger the MCS card.
- Finally, we add a short sleep to ensure that the IOC and DDG HW process the values properly.
"""
start_time = time.time()
########################################
### Burst mode settings ################
########################################
# NOTE We check here if the delay generator is not in burst mode. We check these values
# and set them to the requried values if they differ from the expected ones.
# This has been found empirically to improve stability and avoid HW getting stuck in triggering cycles.
if self.burst_mode.get() == 0:
self.burst_mode.put(1)
if self.burst_delay.get() != 0:
self.burst_delay.put(0)
if self.burst_count.get() != 1:
self.burst_count.put(1)
#########################################
### Setup timing for burst and delays ###
#########################################
frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"]
exp_time = self.scan_info.msg.scan_parameters["exp_time"]
# Burst Period DDG1
# Set burst_period to shutter width
# c/t0 + 2ms + exp_time * burst_count + 1ms
shutter_width = 2e-3 + exp_time * frames_per_trigger + 1e-3
if self.burst_period.get() != shutter_width:
self.burst_period.put(shutter_width)
# Trigger DDG2
# a = t0 + 2ms, b = a + 1us
# a has reference to t0, b has reference to a
# Add delay of 2ms to allow shutter to open
self.set_delay_pairs(channel="ab", delay=2e-3, width=1e-6)
# Trigger shutter
# d = c/t0 + 2ms + exp_time * burst_count + 1ms
# c has reference to t0, d has reference to c
# Shutter opens without delay at t0, closes after exp_time * burst_count + 3ms (2ms open, 1ms close)
self.set_delay_pairs(channel="cd", delay=0, width=shutter_width)
# Trigger extra pulse for MCS OR gate
# f = e + 1us
# e has refernce to d, f has reference to e
self.set_delay_pairs(channel="ef", delay=0, width=1e-6)
# NOTE Add additional sleep to make sure that the IOC and DDG HW process the values properly
# This value has been choosen empirically after testing with the HW. It's
# also just called once per scan and has been found to improve stability of the HW.
time.sleep(0.2)
logger.info(f"DDG {self.name} on_stage completed in {time.time() - start_time:.3f}s.")
def _prepare_mcs_on_trigger(self, mcs: MCSCardCSAXS) -> None:
"""
This method is used by the DDG1 on_trigger method to prepare the MCS card for the next trigger.
It checks that the MCS card is properly prepared before BEC sends a software trigger to the DDG1,
which is needed for step scans.
It relies on the MCS card implementation and needs to be adapted if the MCS card logic changes.
"""
# NOTE First we wait that the MCS card is not acquiring. We add here a timeout of 5s to avoid
# a deadlock in case the MCS card is stuck for some reason. This should not happen normally.
status = CompareStatus(mcs.acquiring, ACQUIRING.DONE)
self.cancel_on_stop(status)
status.wait(timeout=5)
# NOTE Clear the '_omit_mca_callbacks' flag. This makes sure that data received from the mca1...mca3
# counters are forwarded to BEC. Once the flag is set, we create a TransitionStatus DONE->ACQUIRING
# and start the acquisition through erase_start.put(1). Finally, we wait for the card to go to ACQUIRING state.
mcs._omit_mca_callbacks.clear() # pylint: disable=protected-access
status_acquiring = TransitionStatus(mcs.acquiring, [ACQUIRING.DONE, ACQUIRING.ACQUIRING])
self.cancel_on_stop(status_acquiring)
mcs.erase_start.put(1)
return status_acquiring
def _poll_event_status(self) -> None:
"""
Polling loop to retrieve the event status register of the delay generator DDG1.
This method runs in a background thread and the polling is controlled through the
'_poll_thread_run_event' and '_poll_thread_kill_event'. Polling should only become
active when a software trigger was sent in BEC and we are waiting for the burst to complete.
"""
# Main loop of the polling thread. As long as the kill event is not set, the loop continues.
while not self._poll_thread_kill_event.is_set():
# NOTE Main wait event for the polling thread. If the _poll_thread_run_event is not set,
# The thread will wait here. This event is used to start/stop polling from outside the thread,
# as used in on_trigger and on_stop. Please make sure to set this event also when the thread
# should be killed as its otherwise stuck inside the wait.
self._poll_thread_run_event.wait()
# NOTE Set the event to indicate that we are currently still in the poll_loop. This is needed
# as we have to use sleeps of 20ms within the poll loop. These sleeps were empirically detetermined
# to ensure that no state changes are missed. However, these sleeps have the side effect that
# setting the '_poll_thread_run_event' may not immediately stop the polling. Therefore, we need the
# '_poll_thread_poll_loop_done' event to indicate that polling has finished. If this logic is changed,
# it requires careful testing as failure rates can be in the 1 out of 500 events rate, which are still
# not acceptable for operation. The current implementation has been tested with failure rates smaller then
# ~ 1:100000 if failures happened at all.
self._poll_thread_poll_loop_done.clear()
while (
self._poll_thread_run_event.is_set() and not self._poll_thread_kill_event.is_set()
):
try:
self._poll_loop()
except Exception: # pylint: disable=broad-except
content = traceback.format_exc()
logger.error(
f"Exception in polling loop thread, polling continues...\n Error content:\n{content}"
)
# NOTE Set the _poll_thread_poll_loop_done event to indicate that we are done polling. Do not remove!
self._poll_thread_poll_loop_done.set()
def _poll_loop(self) -> None:
"""
This method is the actual poll loop to update the event status from the satus register
of the delay generator DDG1.
It follows a procedure that was established empirically after extended testing with the HW.
Any adaptations to this logic need to be carefully tested to avoid that the HW becomes unstable.
NOTE: Sleeps are important in this logic, and should not be removed or optimized without extensive testing.
20ms has been found to be the minimum sleep time that proofed to be stable in operation.
The logic is as follows:
- Set the 'proc_status' to 1 with use_complete=True to trigger an event based readout of the EventStatusLI.
- Sleep 20ms to give the device time to process the command.
- Check if the kill event or run event are cleared, and exit the loop if so.
- Read the EventStatusLI channel to update the event status.
- Check again if the kill event or run event are cleared, and exit the loop if so.
Please note that any important changes of the status register reading will trigger callbacks
if attached to the event status signal. These callbacks hold the logic to resolve status objects
when waiting for specific events (e.g. end of burst).
"""
self.state.proc_status.put(1, use_complete=True)
# NOTE: Important sleep that has been empirically determined after testing for a long time
# Only remove if absolutely certain that the DDG logic of polling the EventStatusLI works without it.
time.sleep(0.02)
if self._poll_thread_kill_event.is_set() or not self._poll_thread_run_event.is_set():
return
self.state.event_status.get(use_monitor=False)
if self._poll_thread_kill_event.is_set() or not self._poll_thread_run_event.is_set():
return
# NOTE: Again important sleep that has been empirically determined after testing for a long time
# Only remove if certain that logic can be replaced to not risk HW failures.
time.sleep(0.02)
def _start_polling(self) -> None:
"""Start the polling loop in the background thread."""
self._poll_thread_run_event.set()
def _stop_polling(self) -> None:
"""Stop the polling loop in the background thread."""
self._poll_thread_run_event.clear()
def _kill_poll_thread(self) -> None:
"""Kill the polling thread."""
self._poll_thread_kill_event.set()
self._stop_polling()
self._poll_thread.join(timeout=1)
if self._poll_thread.is_alive():
logger.warning("Polling thread did not stop gracefully.")
else:
logger.info("Polling thread stopped.")
def _prepare_trigger_status_event(
self, timeout: float | None = None
) -> StatusBitsCompareStatus:
"""
Method to prepare a status object that indicates the end of a burst cycle.
It also sets up a callback to cancel the polling of the event status register
if the status is cancelled externally (e.g. by stopping the device). In addition,
a timeout can either be specified, or is automatically calculated based on the
exposure time, frames_per_trigger and a default extra time of 5 seconds.
Args:
timeout (float | None, optional): Timeout for the status object. If None, a
default timeout based on exposure time and frames_per_trigger is used.
Returns:
StatusBitsCompareStatus:
"""
if timeout is None:
# Default timeout of 5 seconds + exposure time * frames_per_trigger
timeout = 5 + self.scan_info.msg.scan_parameters.get(
"exp_time", 0.1
) * self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1)
# Callback to cancel the status if the device is stopped
def cancel_cb(status: CompareStatus) -> None:
"""Callback to cancel the status if the device is stopped."""
logger.debug("DDG1 end of burst detected, stopping polling loop.")
if status.done:
self._stop_polling()
# Run false is important to ensure that the status is only checked on the next event status update
status = StatusBitsCompareStatus(
self.state.event_status, STATUSBITS.END_OF_BURST, timeout=timeout, run=False
)
status.add_callback(cancel_cb)
self.cancel_on_stop(status)
return status
def on_trigger(self) -> DeviceStatus:
"""
This method is called from BEC as a software trigger.
It follows a specific procedure to ensure that the DDG1 and MCS card are properly handled
on a trigger event. The established logic is as follows:
- Stop polling the event status register to avoid that the polling loop is still active
before sending the software trigger. This needs to be done to avoid conflicts
in reading the event status register.
- Wait for the _poll_thread_poll_loop_done event to ensure that the polling loop is no
longer active. A timeout of 1s is plenty as sleeps of 20ms are used in the poll loop.
- Add an extra sleep of 20ms to make sure that the HW is again ready to process new commands.
This has been found empirically after long testing to improve stability.
- If the MCS card is present in the current session of BEC, prepare the card for the next trigger.
- Prepare a status StatusBitsCompareStatus that will be resolved once the burst is done.
- Start the polling loop again to monitor the event status register.
- Send the software trigger to the DDG1
- Return the status object to BEC which will automatically resolve once the status register has
the END_OF_BURST bit set. The callback of the status object will also stop the polling loop.
"""
self._stop_polling()
self._poll_thread_poll_loop_done.wait(timeout=1)
# NOTE: This sleep is important to ensure that the HW is ready to process new commands.
# It has been empirically determined after long testing that this improves stability.
time.sleep(0.02)
# NOTE If the MCS card is present in the current session of BEC,
# we prepare the card for the next trigger. The procedure is implemented
# in the '_prepare_mcs_on_trigger' method.
# Prepare the MCS card for the next software trigger
mcs = self.device_manager.devices.get("mcs", None)
if mcs is None or mcs.enabled is False:
logger.info("Did not find mcs card with name 'mcs' in current session")
else:
status_mcs = self._prepare_mcs_on_trigger(mcs)
# NOTE Timeout of 3s should be plenty, any longer wait should checked. If this happens to crash
# an acquisition regularly with a WaitTimeoutError, the timeout can be increased but it should
# be investigated why the EPICS interface is slow to respond.
status_mcs.wait(timeout=3)
# Prepare StatusBitsCompareStatus to resolve once the END_OF_BURST bit was set.
status = self._prepare_trigger_status_event()
# Start polling thread again to monitor event status
self._start_polling()
# Trigger the DDG1
self.trigger_shot.put(1, use_complete=True)
return status
def on_stop(self) -> None:
"""Stop the delay generator HW and polling thread when the device is stopped."""
self.stop_ddg()
self._stop_polling()
def on_destroy(self) -> None:
"""Clean up resources when the device is destroyed."""
self.stop_ddg()
self._kill_poll_thread()
if __name__ == "__main__":
ddg = DDG1(name="ddg1", prefix="X12SA-CPCL-DDG1:")
ddg.wait_for_connection(all_signals=True, timeout=30)
ddg.summary()

View File

@@ -0,0 +1,215 @@
"""
DDG2 delay generator
This module implements the DDG2 delay generator logic for the CSAXS beamline.
Please check also the code for DDG1, aswell as the attached PDF trigger_scheme_ddg1_ddg2.pdf
The DDG2 is responsible for creating a burst of triggers for all relevant detectors.
It will receive a be triggered from the DDG1 through the EXT/EN channel.
A brief summary of the DDG2 logic:
DELAY PAIRS:
- EXT/EN is connected to the DDG1 delay pair ab.
- DelayPair ab is connected to a multiplexer, multiplexing the trigger to the detectors.
DELAY CHANNELS:
- a = t0
- b = a + (exp_time - READOUT_TIMES)
Burst mode is enabled:
- Burst count is set to the number of frames per trigger.
- Burst delay is set to 0.
- Burst period is set to the exposure time.
"""
import time
from bec_lib.logger import bec_logger
from ophyd_devices import DeviceStatus, StatusBase
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import (
CHANNELREFERENCE,
OUTPUTPOLARITY,
STATUSBITS,
TRIGGERSOURCE,
AllChannelNames,
ChannelConfig,
DelayGeneratorCSAXS,
LiteralChannels,
)
logger = bec_logger.logger
########################
## DEFAULT SETTINGS ####
########################
# NOTE Default channel configuration for the DDG2 delay generator channels
_DEFAULT_CHANNEL_CONFIG: ChannelConfig = {
"amplitude": 5.0,
"offset": 0.0,
"polarity": OUTPUTPOLARITY.POSITIVE,
"mode": "ttl",
}
# NOTE Default IO configuration for all channels in DDG2
# Each channel uses the same default configuration as defined above
# If needed, individual channel configurations should be modified here.
DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = {
"t0": _DEFAULT_CHANNEL_CONFIG,
"ab": _DEFAULT_CHANNEL_CONFIG,
"cd": _DEFAULT_CHANNEL_CONFIG,
"ef": _DEFAULT_CHANNEL_CONFIG,
"gh": _DEFAULT_CHANNEL_CONFIG,
}
DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.EXT_RISING_EDGE
# NOTE Default readout times for the detectors connected to DDG2
# These values are used to calculate the difference between the burst_period and the pulse width of
# individual channel pairs. They also mark a lower limit for the exposure time. Needs to be
# adjusted if the exposure time should possibly go below 0.2 ms.
DEFAULT_READOUT_TIMES = {"ab": 2e-4, "cd": 2e-4, "ef": 2e-4, "gh": 2e-4} # 0.2 ms 5kHz
# NOTE Default refernce settings for each channel in DDG2
DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [
("A", CHANNELREFERENCE.T0),
("B", CHANNELREFERENCE.A),
("C", CHANNELREFERENCE.T0),
("D", CHANNELREFERENCE.C),
("E", CHANNELREFERENCE.T0),
("F", CHANNELREFERENCE.E),
("G", CHANNELREFERENCE.T0),
("H", CHANNELREFERENCE.G),
]
###############################
## DDG2 IMPLEMENTATION ########
###############################
class DDG2(PSIDeviceBase, DelayGeneratorCSAXS):
"""
Implementation of the DelayGenerator DDG2 for the cSAXS beamline. This delay generator is
reponsible to create triggers for the detectors. It is configured in burst mode. Please
check the module docstring, the module README and the attached PDF 'trigger_scheme_ddg1_ddg2.pdf'
for more information about the expected cabling and trigger logic.
The IOC prefix is 'X12SA-CPCL-DDG2:'.
Args:
name (str): Name of the device.
prefix (str, optional): EPICS prefix for the device. Defaults to ''.
scan_info (ScanInfo | None, optional): Scan info object. Defaults to None.
device_manager (DeviceManagerBase | None, optional): Device manager. Defaults to None.
Implementation of DelayGeneratorCSAXS for the CSAXS master trigger delay generator at X12SA-CPCL-DDG2.
This device is responsible for creating triggers in burst mode and is connected to a multiplexer that
distributes the trigger to the detectors. The DDG2 is triggered by the DDG1 through the EXT/EN channel.
"""
# pylint: disable=attribute-defined-outside-init
def on_connected(self) -> None:
"""
This method is called after the device is initialized and all signals are connected. This happens
when a device configuration is loaded in BEC.
It sets the default values for this device - intended to overwrite everything to a usable default state.
For this purpose, we use the DEFAULT SETTINGS defined at the top of this module.
The following procedure is followed:
- Stop the DDG to ensure it is not running.
- Then, we set the DEFAULT_IO_CONFIG for each channel, the trigger source to DEFAULT_TRIGGER_SOURCE,
and the channel references to DEFAULT_REFERENCES.
"""
self.stop_ddg()
# NOTE Please adjust the default settings under 'DEFAULT SETTINGS' at the top of this module if needed.
# This makes sure that we have a well defined default state for the DDG2 device.
for channel, config in DEFAULT_IO_CONFIG.items():
self.set_io_values(channel, **config)
self.set_trigger(DEFAULT_TRIGGER_SOURCE)
self.set_references_for_channels(DEFAULT_REFERENCES)
def on_stage(self) -> DeviceStatus | StatusBase | None:
"""
This method is called when the device is staged before a scan. All information about the scan
is available through self.scan_info.msg at this point. The DDG2 needs to be configured to
create a sequence of TTL pulses in burst mode that are sent to the detectors. It therefore needs
to know the exposure time and frames per trigger from the self.scan_info.msg.scan_parameters.
This logic is robust for step scans as well as fly scans, as the DDG2 is triggered by the DDG1
through the EXT/EN channel.
"""
start_time = time.time()
########################################
### Burst mode settings ################
########################################
# NOTE Only adjust settings if needed. DDG2 should always be in burst mode when used at CSAXS.
if self.burst_mode.get() == 0:
self.burst_mode.put(1)
# Ensure that there is no delay for the burst
if self.burst_delay.get() != 0:
self.burst_delay.put(0)
exp_time = self.scan_info.msg.scan_parameters["exp_time"]
frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"]
# NOTE Check if the exposure time is longer than all readout times.
# Raise a ValueError if requested exposure time is too short.
if any(exp_time <= rt for rt in DEFAULT_READOUT_TIMES.values()):
raise ValueError(
f"Exposure time {exp_time} is too short for the readout times {DEFAULT_READOUT_TIMES}"
)
#########################################
### Setup timing for burst and delays ###
#########################################
# Burst Period DDG2 settings. Only adjust them if needed.
if self.burst_count.get() != frames_per_trigger:
self.burst_count.put(frames_per_trigger)
if self.burst_period.get() != exp_time:
self.burst_period.put(exp_time)
# Calculate the pulse width for the channel pair 'ab'
burst_pulse_width = exp_time - DEFAULT_READOUT_TIMES["ab"]
# Trigger detectors with delay 0, and pulse width = exp_time - readout_time
self.set_delay_pairs(channel="ab", delay=0, width=burst_pulse_width)
logger.info(f"DDG {self.name} on_stage completed in {time.time() - start_time:.3f}s.")
def on_pre_scan(self):
"""
Method that is called just before a scan starts. It was observed that a short delay of 50ms
improves the overall stability in operation. This may be removed as other parts were adjusted,
but for now we will keep it as the delay is short.
"""
# NOTE Short delay to allow for the HW to process the commands before the scan starts.
# This may no longer be needed after other adjustments, and may be removed in the future.
time.sleep(0.05)
def on_trigger(self) -> DeviceStatus | StatusBase | None:
"""
DDG2 does not implement any trigger specific logic as it is triggered by DDG1 through the EXT/EN channel.
"""
pass
def on_stop(self) -> None:
"""Stop the delay generator"""
self.stop_ddg()
if __name__ == "__main__":
ddg = DDG2(name="ddg2", prefix="X12SA-CPCL-DDG2:")
ddg.wait_for_connection(all_signals=True, timeout=30)
ddg.summary()

View File

@@ -0,0 +1,769 @@
"""
Delay generator implementation for CSAXS.
Detailed information can be found in the manual:
https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf
On the beamline consoles, the caqtdm panel can be started via:
caqtdm -noMsg -attach -macro P=X12SA-CPCL-DDG,R=1: srsDG645.ui
R=1,2,3 for 3 different DDG units installed at CSAXS.
"""
import enum
import time
from typing import Literal, TypedDict
from bec_lib.logger import bec_logger
from ophyd import Component as Cpt
from ophyd import Device, EpicsSignal, EpicsSignalRO, Kind, Signal
from ophyd_devices import StatusBase, SubscriptionStatus
from typeguard import typechecked
from csaxs_bec.devices.epics.delay_generator_csaxs.error_registry import ERROR_CODES
logger = bec_logger.logger
DelayChannelNames = Literal["ab", "cd", "ef", "gh"]
AllChannelNames = Literal["t0", "ab", "cd", "ef", "gh"]
LiteralChannels = Literal["A", "B", "C", "D", "E", "F", "G", "H"]
class CHANNELREFERENCE(enum.Enum):
T0 = 0
A = 1
B = 2
C = 3
D = 4
E = 5
F = 6
G = 7
H = 8
class BURSTCONFIG(enum.Enum):
"""Enum option for burst_config signal of the delay generator.
ALL_CYCLES: T0 triggere for all cycles.
FIRST_CYCLE: T0 only triggered for the first cycle.
"""
ALL_CYCLES = 0
FIRST_CYCLE = 1
class TRIGGERSOURCE(enum.Enum):
"""Enum options for the trigger_source signal of the delay generator."""
INTERNAL = 0
EXT_RISING_EDGE = 1
EXT_FALLING_EDGE = 2
SS_EXT_RISING_EDGE = 3
SS_EXT_FALLING_EDGE = 4
SINGLE_SHOT = 5
LINE = 6
class TRIGGERINHIBIT(enum.Enum):
"""Enum options for the trigger_inhibit signal of the delay generator."""
OFF = 0
TRIGGERS = 1
AB = 2
AB_CD = 3
AB_CD_EF = 4
AB_CD_EF_GH = 5
class OUTPUTPOLARITY(enum.Enum):
"""Enum options for the polarity signal of the static pair."""
NEGATIVE = 0
POSITIVE = 1
class PROC_EVENT_MODE(int, enum.Enum):
"""Read mode for MCS channels."""
PASSIVE = 0
EVENT = 1
IO_INTR = 2
FREQ_0_1HZ = 3
FREQ_0_2HZ = 4
FREQ_0_5HZ = 5
FREQ_1HZ = 6
FREQ_2HZ = 7
FREQ_5HZ = 8
FREQ_10HZ = 9
FREQ_100HZ = 10
class STATUSBITS(enum.IntFlag):
"""Bit flags for the status signal of the delay generator."""
NONE = 0 << 0 # No status bits set.
TRIG = 1 << 0 # Got a trigger.
RATE = 1 << 1 # Got a trigger while a delay or burst was in progress.
END_OF_DELAY = 1 << 2 # A delay cycle has completed.
END_OF_BURST = 1 << 3 # A burst cycle has completed.
INHIBIT = 1 << 4 # A trigger or output delay cycle was inhibited.
ABORT_DELAY = 1 << 5 # A delay cycle was aborted early.
PLL_UNLOCK = 1 << 6 # The 100 MHz PLL came unlocked.
RB_UNLOCK = 1 << 7 # The installed Rb oscillator is unlocked.
def describe(self) -> dict:
"""Return a description of the status bits."""
descriptions = {
STATUSBITS.NONE: "No status bits set.",
STATUSBITS.TRIG: "Got a trigger.",
STATUSBITS.RATE: "Got a trigger while a delay or burst was in progress.",
STATUSBITS.END_OF_DELAY: "A delay cycle has completed.",
STATUSBITS.END_OF_BURST: "A burst cycle has completed.",
STATUSBITS.INHIBIT: "A trigger or output delay cycle was inhibited.",
STATUSBITS.ABORT_DELAY: "A delay cycle was aborted early.",
STATUSBITS.PLL_UNLOCK: "The 100 MHz PLL came unlocked.",
STATUSBITS.RB_UNLOCK: "The installed Rb oscillator is unlocked.",
}
return {flag.name: descriptions[flag] for flag in STATUSBITS if flag in self}
class StatusBitsCompareStatus(SubscriptionStatus):
"""Compare status for STATUSBITS comparison."""
def __init__(
self,
signal: EpicsSignalRO,
value: STATUSBITS,
raise_states: list[STATUSBITS] | None = None,
*args,
event_type=None,
timeout: float | None = None,
add_delay: float | None = None,
settle_time: float = 0,
run: bool = True,
**kwargs,
):
"""Initialize the compare status with a signal."""
self._signal = signal
self._value = value
self._add_delay = add_delay or 0
self._raise_states = raise_states or []
super().__init__(
obj=signal,
callback=self._compare_callback,
timeout=timeout,
settle_time=settle_time,
event_type=event_type,
run=run,
)
def _compare_callback(self, *args, value, **kwargs) -> bool:
"""Callback for subscription status"""
logger.debug(f"StatusBitsCompareStatus: Received value {value}")
obj = kwargs.get("obj", None)
if obj is None:
name = "no object received"
else:
name = obj.name
if any((STATUSBITS(value) & state) == state for state in self._raise_states):
self.set_exception(
ValueError(
f"Status bits {STATUSBITS(value).describe()} raised an exception: {self._raise_states}"
)
)
return False
if self._add_delay != 0:
time.sleep(self._add_delay)
logger.debug(
f"Returning comparison for {name}: {(STATUSBITS(value) & self._value) == self._value}"
)
return (STATUSBITS(value) & self._value) == self._value
class ChannelConfig(TypedDict):
amplitude: float | None
offset: float | None
polarity: OUTPUTPOLARITY | Literal[0, 1] | None
mode: Literal["ttl", "nim"] | None
class StaticPair(Device):
"""
Class to represent a static pair (T0, aswell as all AB, CB, EF, GH channels).
It allows setting the logic levels, but the timing is fixed.
The signal is high after receiving the trigger until the end of the holdoff period.
"""
ttl_mode = Cpt(
EpicsSignal,
"OutputModeTtlSS.PROC",
kind=Kind.omitted,
auto_monitor=True,
doc="Set the output mode to TTL",
)
nim_mode = Cpt(
EpicsSignal,
"OutputModeNimSS.PROC",
kind=Kind.omitted,
auto_monitor=True,
doc="Set the output mode to NIM",
)
polarity = Cpt(
EpicsSignal,
"OutputPolarityBI",
write_pv="OutputPolarityBO",
name="polarity",
kind=Kind.omitted,
auto_monitor=True,
doc="Control the polarity of the output signal. POS 1 or NEG 0",
)
amplitude = Cpt(
EpicsSignal,
"OutputAmpAI",
write_pv="OutputAmpAO",
name="amplitude",
kind=Kind.omitted,
auto_monitor=True,
doc="Amplitude of the output signal in volts.",
)
offset = Cpt(
EpicsSignal,
"OutputOffsetAI",
write_pv="OutputOffsetAO",
name="offset",
kind=Kind.omitted,
auto_monitor=True,
doc="Offset of the output signal in volts.",
)
class Channel(Device):
"""
Represents a single channel A, B, C, ... of the delay generator.
"""
setpoint = Cpt(
EpicsSignal,
write_pv="DelayAO",
read_pv="DelayAI",
put_complete=True,
auto_monitor=True,
kind=Kind.omitted,
doc="Setpoint value for the delay of the channel",
)
reference = Cpt(
EpicsSignal,
"ReferenceMO",
put_complete=True,
kind=Kind.omitted,
auto_monitor=True,
doc="Reference channel T0,A,B,.. for the delay of the setpoint",
)
def __init__(self, *args, **kwargs):
"""
Initialize the channel with a setpoint and reference signal.
"""
# The read PV in EpicsSignal does not receive the prefix.. so we need to add it manually.
self.__class__.__dict__["setpoint"].kwargs["read_pv"] = args[0] + "DelayAI"
super().__init__(*args, **kwargs)
class WidthSignal(Signal):
"""A signal that represents the width of a channel."""
def get(self, **kwargs) -> float:
"""
Get the width of the channel.
Returns:
float: The width of the channel in seconds.
"""
parent: _DelayPairBase = self._parent # type: ignore
return parent.ch2.setpoint.get() - parent.ch1.setpoint.get() # type: ignore
def check_value(self, value: float) -> float:
"""Check if the value is larger equal to 0"""
if value >= 0:
return value
else:
raise ValueError(f"Width must be larger ot equal 0, got {value} seconds.")
def put(self, value: float, **kwargs):
"""
Set the width of the channel.
Args:
value (float): The width to set in seconds.
"""
self.check_value(value)
parent: _DelayPairBase = self._parent # type: ignore
ch1_setpoint: float = parent.ch1.setpoint.get() # type: ignore
parent.ch2.setpoint.put(ch1_setpoint + value, **kwargs)
def set(self, value: float, **kwargs):
"""
Set the width of the channel.
Args:
value (float): The width to set in seconds.
"""
status = StatusBase()
self.put(value, **kwargs)
status.set_finished()
return status
class DelaySignal(Signal):
"""A signal that represents the delay of a channel."""
def get(self, **kwargs):
"""
Get the delay of the channel.
Returns:
float: The delay of the channel in seconds.
"""
parent: _DelayPairBase = self._parent # type: ignore
return parent.ch1.setpoint.get()
def put(self, value: float, **kwargs):
"""
Set the delay of the channel.
Args:
value (float): The delay to set in seconds.
"""
parent: _DelayPairBase = self._parent # type: ignore
parent.ch1.setpoint.put(value, **kwargs)
parent.ch2.setpoint.put(value + parent.width.get(), **kwargs)
def set(self, value: float, **kwargs):
"""
Set the width of the channel.
Args:
value (float): The width to set in seconds.
"""
status = StatusBase()
self.put(value, **kwargs)
status.set_finished()
return status
class _DelayPairBase(Device):
"""Base class for delay pairs. Children have to implement ch1,ch2 for
the respective delay channels. The class attributes have to be called
ch1, ch2 for width and delay signals to work."""
ch1: Cpt[Channel]
ch2: Cpt[Channel]
io: Cpt[StaticPair]
width = Cpt(
WidthSignal, name="width", kind=Kind.config, doc="Width of TTL pulse for delay pair"
)
delay = Cpt(
DelaySignal, name="delay", kind=Kind.config, doc="Delay of TTL pulse for delay pair"
)
class DelayPairAB(_DelayPairBase):
ch1 = Cpt(Channel, "A", name="A", kind=Kind.omitted, doc="Channel A")
ch2 = Cpt(Channel, "B", name="B", kind=Kind.omitted, doc="Channel B")
io = Cpt(StaticPair, "AB", name="io", kind=Kind.omitted, doc="IO for delay pair AB")
class DelayPairCD(_DelayPairBase):
ch1 = Cpt(Channel, "C", name="C", kind=Kind.omitted, doc="Channel C")
ch2 = Cpt(Channel, "D", name="D", kind=Kind.omitted, doc="Channel D")
io = Cpt(StaticPair, "CD", name="io", kind=Kind.omitted, doc="IO for delay pair CD")
class DelayPairEF(_DelayPairBase):
ch1 = Cpt(Channel, "E", name="E", kind=Kind.omitted, doc="Channel E")
ch2 = Cpt(Channel, "F", name="F", kind=Kind.omitted, doc="Channel F")
io = Cpt(StaticPair, "EF", name="io", kind=Kind.omitted, doc="IO for delay pair EF")
class DelayPairGH(_DelayPairBase):
ch1 = Cpt(Channel, "G", name="G", kind=Kind.omitted, doc="Channel G")
ch2 = Cpt(Channel, "H", name="H", kind=Kind.omitted, doc="Channel H")
io = Cpt(StaticPair, "GH", name="io", kind=Kind.omitted, doc="IO for delay pair GH")
class DelayGeneratorEventStatus(Device):
"""Subdevice to represent the event state of the delay generator."""
event_status = Cpt(
EpicsSignalRO,
"EventStatusLI",
name="event_status",
kind=Kind.omitted,
doc="Event status register for the delay generator",
)
proc_status = Cpt(
EpicsSignal,
"EventStatusLI.PROC",
name="proc_status",
kind=Kind.omitted,
doc="Poll and flush the latest event status register entry from the HW to the event_status signal",
)
proc_status_mode = Cpt(
EpicsSignal,
"EventStatusLI.SCAN",
kind=Kind.omitted,
doc="Readout mode for transferring data from status buffer to the event_status signal.",
)
class DelayGeneratorCSAXS(Device):
"""
Delay Generator Stanford Research DG645. This implements an interface for the DG645 delay generator.
Detailed information can be found in the manual:
https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf
The DG645 has 8 channels, each with a delay and pulse width. The channels are implemented as DelayPair objects (AB etc.).
Each pair has a TTL pulse width, delay and a reference signal to which they are being triggered.
In addition, the io layer allows setting amplitude, offset and polarity for each pair.
"""
# USER_ACCESS = [
# "set_channel_reference",
# "set_references_for_channels",
# "set_io_values",
# "set_trigger",
# ]
_pv_timeout: float = 5 # Default timeout for PV operations in seconds
# Front Panel
t0 = Cpt(StaticPair, "T0", name="t0", doc="T0 static pair")
ab = Cpt(DelayPairAB, "", name="ab", doc="Delay pair AB")
cd = Cpt(DelayPairCD, "", name="cd", doc="Delay pair CD")
ef = Cpt(DelayPairEF, "", name="ef", doc="Delay pair EF")
gh = Cpt(DelayPairGH, "", name="gh", doc="Delay pair GH")
state = Cpt(DelayGeneratorEventStatus, "", name="state", doc="Subdevice for event status")
status_msg = Cpt(
EpicsSignalRO,
"StatusSI",
name="status_msg",
kind=Kind.omitted,
auto_monitor=True,
doc="Status message from the delay generator",
)
status_msg_clear = Cpt(
EpicsSignal,
"StatusClearBO",
name="status_msg_clear",
kind=Kind.omitted,
doc="Clear the status message",
)
trigger_holdoff = Cpt(
EpicsSignal,
"TriggerHoldoffAI",
write_pv="TriggerHoldoffAO",
name="trigger_holdoff",
kind=Kind.config,
)
trigger_inhibit = Cpt(
EpicsSignal,
"TriggerInhibitMI",
write_pv="TriggerInhibitMO",
name="trigger_inhibit",
kind=Kind.omitted,
)
trigger_source = Cpt(
EpicsSignal,
"TriggerSourceMI",
write_pv="TriggerSourceMO",
name="trigger_source",
kind=Kind.omitted,
doc="Trigger Source for the DDG, options in TRIGGERSOURCE",
)
trigger_level = Cpt(
EpicsSignal,
"TriggerLevelAI",
write_pv="TriggerLevelAO",
name="trigger_level",
kind=Kind.omitted,
)
trigger_rate = Cpt(
EpicsSignal,
"TriggerRateAI",
write_pv="TriggerRateAO",
name="trigger_rate",
kind=Kind.omitted,
)
trigger_shot = Cpt(
EpicsSignal,
"TriggerDelayBO",
name="trigger_shot",
kind=Kind.omitted,
doc="Software trigger, needs to be in correct mode to work",
)
burst_mode = Cpt(
EpicsSignal,
"BurstModeBI",
write_pv="BurstModeBO",
name="burst_mode",
kind=Kind.omitted,
auto_monitor=True,
doc="Enable or disable burst mode. 1 = enabled, 0 = disabled.",
)
burst_config = Cpt(
EpicsSignal,
"BurstConfigBI",
write_pv="BurstConfigBO",
name="burst_config",
kind=Kind.omitted,
doc="Configuration of T0 during burst. Can be ALL_CYCLES (0) or FIRST_CYCLE (1) .",
)
burst_count = Cpt(
EpicsSignal,
"BurstCountLI",
write_pv="BurstCountLO",
name="burst_count",
kind=Kind.omitted,
doc="Number of bursts to trigger in burst mode. Must be >0.",
)
burst_delay = Cpt(
EpicsSignal,
"BurstDelayAI",
write_pv="BurstDelayAO",
name="burst_delay",
kind=Kind.omitted,
auto_monitor=True,
doc="Delay before bursts start in seconds. Must be >=0.",
)
burst_period = Cpt(
EpicsSignal,
"BurstPeriodAI",
write_pv="BurstPeriodAO",
name="burst_period",
kind=Kind.omitted,
doc="Period of the bursts in seconds. Must be >0.",
)
def proc_event_status(self) -> None:
"""The reading must be manually triggered to update the event status."""
self.state.proc_status.put(1)
def wait_for_event_status(
self, value: STATUSBITS, timeout: float | None = None
) -> StatusBitsCompareStatus:
"""
Wait for a specific event status.
Args:
value (STATUSBITS): The status bits to wait for.
timeout (float): The maximum time to wait in seconds.
"""
return StatusBitsCompareStatus(
signal=self.state.event_status, value=value, timeout=timeout, run=True
)
def set_trigger(self, source: TRIGGERSOURCE | int) -> None:
"""
Set the trigger source.
Args:
source (TriggerSource | int): The trigger source
INTERNAL = 0
EXT_RISING_EDGE = 1
EXT_FALLING_EDGE = 2
SS_EXT_RISING_EDGE = 3
SS_EXT_FALLING_EDGE = 4
SINGLE_SHOT = 5
LINE = 6
"""
if isinstance(source, TRIGGERSOURCE):
self.trigger_source.set(source.value).wait(self._pv_timeout)
else:
self.trigger_source.set(int(source)).wait(self._pv_timeout)
@typechecked
def burst_enable(
self,
count: int,
delay: float,
period: float,
config: Literal["all", "first"] | BURSTCONFIG = "first",
) -> None:
"""Enable burst mode with valid parameters.
Args:
count (int): Number of bursts >0
delay (float): Delay before bursts start in seconds >=0
period (float): Period of the bursts in seconds >0
config (str): Configuration of T0 duiring burst.
In addition, to simplify triggering of other instruments synchronously with the burst,
the T0 output may be configured to fire on the first delay cycle of the burst,
rather than for all delay cycles as is normally the case. BURSTCONFIG
"""
# Check inputs first
if count <= 0:
raise ValueError(f"Count must be >0, provided: {count}")
if delay < 0:
raise ValueError(f"Delay must be >=0, provided: {delay}")
if period <= 0:
raise ValueError(f"Period must be >0, provided: {period}")
self.burst_mode.set(1).wait(timeout=self._pv_timeout)
self.burst_count.set(count).wait(timeout=self._pv_timeout)
self.burst_delay.set(delay).wait(timeout=self._pv_timeout)
self.burst_period.set(period).wait(timeout=self._pv_timeout)
if config == "all":
self.burst_config.set(BURSTCONFIG.ALL_CYCLES.value).wait(timeout=self._pv_timeout)
elif config == "first":
self.burst_config.set(BURSTCONFIG.FIRST_CYCLE.value).wait(timeout=self._pv_timeout)
def burst_disable(self) -> None:
"""Disable burst mode"""
self.burst_mode.set(0).wait(timeout=self._pv_timeout)
@typechecked
def set_io_values(
self,
channel: AllChannelNames | list[AllChannelNames],
amplitude: float | None = None,
offset: float | None = None,
polarity: OUTPUTPOLARITY | Literal[0, 1] | None = None,
mode: Literal["ttl", "nim"] | None = None,
) -> None:
"""Set the IO values for the static pair.
Args:
channel (str | list[str]): Channel(s) to set the IO values for.
Can be "t0", "ab", "cd", "ef", "gh" or a list of these.
If a list is provided, the same values will be set for all channels.
amplitude (float): Amplitude of the output signal in volts.
offset (float): Offset of the output signal in volts.
polarity (OUTPUTPOLARITY | int): Polarity of the output signal.
ttl_mode (bool): If True, set the output to TTL mode.
nim_mode (bool): If True, set the output to NIM mode.
If both ttl_mode and nim_mode are set to True,
a ValueError is raised.
"""
if isinstance(channel, str):
channel = [channel]
for ch in channel:
if ch == "t0":
io_channel = self.t0
else:
io_channel = getattr(getattr(self, ch), "io")
if amplitude is not None:
io_channel.amplitude.set(amplitude).wait(timeout=self._pv_timeout)
if offset is not None:
io_channel.offset.set(offset).wait(timeout=self._pv_timeout)
if polarity is not None:
if isinstance(polarity, OUTPUTPOLARITY):
io_channel.polarity.set(polarity.value).wait(timeout=self._pv_timeout)
else:
io_channel.polarity.set(int(polarity)).wait(timeout=self._pv_timeout)
if mode == "ttl":
io_channel.ttl_mode.set(1).wait(timeout=self._pv_timeout)
if mode == "nim":
io_channel.nim_mode.set(1).wait(timeout=self._pv_timeout)
def set_delay_pairs(
self,
channel: DelayChannelNames | list[DelayChannelNames],
delay: float | list[float] | None = None,
width: float | list[float] | None = None,
) -> None:
"""Set the delay and width for a specific channel pair.
Args:
channel (str): Channel pair to set the delay and width for.
Can be "ab", "cd", "ef", "gh".
delay (float): Delay in seconds to set for the channel pair.
width (float): Width in seconds to set for the channel pair.
"""
if isinstance(channel, str):
channel = [channel]
if isinstance(delay, (float, int)):
delay = [float(delay)] * len(channel)
if isinstance(width, (float, int)):
width = [float(width)] * len(channel)
if delay is not None:
if len(delay) != len(channel):
raise ValueError(
f"Length of delay {len(delay)} must match length of channel {len(channel)}."
)
for ii, ch in enumerate(channel):
delay_channel = getattr(self, ch)
delay_channel.delay.put(delay[ii])
if width is not None:
if len(width) != len(channel):
raise ValueError(
f"Length of width {len(width)} must match length of channel {len(channel)}."
)
for ii, ch in enumerate(channel):
delay_channel = getattr(self, ch)
delay_channel.width.put(width[ii])
def _get_literal_channel(self, channel: LiteralChannels) -> Channel:
return {
"A": self.ab.ch1,
"B": self.ab.ch2,
"C": self.cd.ch1,
"D": self.cd.ch2,
"E": self.ef.ch1,
"F": self.ef.ch2,
"G": self.gh.ch1,
"H": self.gh.ch2,
}[channel]
def set_channel_reference(self, channel: LiteralChannels, reference_channel: CHANNELREFERENCE):
"""Set the reference channel for a specific channel.
Args:
channel (LiteralChannels): The channel to set the reference for.
reference_channel (CHANNELREFERENCE): The reference channel to set.
"""
self._get_literal_channel(channel).reference.put(reference_channel.value)
def set_references_for_channels(
self, channels_and_refs: list[tuple[LiteralChannels, CHANNELREFERENCE]]
):
"""Set the reference channels for multiple channels.
Args:
channels_and_refs (list[tuple[LiteralChannels, CHANNELREFERENCE]]): A list of
tuples where each tuple contains a channel and its corresponding reference channel.
"""
for ch, ref in channels_and_refs:
self.set_channel_reference(ch, ref)
def stop_ddg(self) -> None:
"""Stop the delay generator by setting the burst mode to 0"""
self.burst_mode.put(0)
def reset_error(self) -> None:
"""Reset the error status message of the delay generator."""
self.status_msg_clear.put(1)
def get_error_msg(self) -> str:
"""Get the error message from the delay generator."""
msg = self.status_msg.get()
if msg in ERROR_CODES:
return ERROR_CODES[msg]
else:
return f"Unknown error code: {msg}"
if __name__ == "__main__":
ddg = DelayGeneratorCSAXS(name="ddg", prefix="X12SA-CPCL-DDG1:")
ddg.wait_for_connection(all_signals=True, timeout=30)
ddg.summary()

View File

@@ -0,0 +1,73 @@
ERROR_CODES: dict[str, str] = {
"STATUS OK": "No more errors left in the queue.", # renamed apparently from the IOC for 'No Error' to 'STATUS OK'
"Illegal Value": "A parameter was out of range.",
"Illegal Mode": "The action is illegal in the current mode.",
"Illegal Delay": "The requested delay is out of range.",
"Illegal Link": "The requested delay linkage is illegal.",
"Recall Failed": "Recall of instrument settings failed; settings were invalid.",
"Not Allowed": "Action not allowed: instrument is locked by another interface.",
"Failed Self Test": "The DG645 self test failed.",
"Failed Auto Calibration": "The DG645 auto calibration failed.",
"Lost Data": "Output buffer overflow or data lost due to communication error.",
"No Listener": "No GPIB listeners; pending output discarded.",
"Failed ROM Check": "ROM checksum failed; firmware likely corrupted.",
"Failed Offset T0 Test": "Self test of offset functionality for T0 failed.",
"Failed Offset AB Test": "Self test of offset functionality for AB failed.",
"Failed Offset CD Test": "Self test of offset functionality for CD failed.",
"Failed Offset EF Test": "Self test of offset functionality for EF failed.",
"Failed Offset GH Test": "Self test of offset functionality for GH failed.",
"Failed Amplitude T0 Test": "Self test of amplitude functionality for T0 failed.",
"Failed Amplitude AB Test": "Self test of amplitude functionality for AB failed.",
"Failed Amplitude CD Test": "Self test of amplitude functionality for CD failed.",
"Failed Amplitude EF Test": "Self test of amplitude functionality for EF failed.",
"Failed Amplitude GH Test": "Self test of amplitude functionality for GH failed.",
"Failed FPGA Communications Test": "Self test of FPGA communications failed.",
"Failed GPIB Communications Test": "Self test of GPIB communications failed.",
"Failed DDS Communications Test": "Self test of DDS communications failed.",
"Failed Serial EEPROM Communications Test": "Self test of serial EEPROM failed.",
"Failed Temperature Sensor Communications Test": "Temp sensor communication failed.",
"Failed PLL Communications Test": "PLL communication self test failed.",
"Failed DAC 0 Communications Test": "Self test of DAC 0 failed.",
"Failed DAC 1 Communications Test": "Self test of DAC 1 failed.",
"Failed DAC 2 Communications Test": "Self test of DAC 2 failed.",
"Failed Sample and Hold Operations Test": "Sample and hold self test failed.",
"Failed Vjitter Operations Test": "Vjitter operation self test failed.",
"Failed Channel T0 Analog Delay Test": "Analog delay test for T0 failed.",
"Failed Channel T1 Analog Delay Test": "Analog delay test for T1 failed.",
"Failed Channel A Analog Delay Test": "Analog delay test for A failed.",
"Failed Channel B Analog Delay Test": "Analog delay test for B failed.",
"Failed Channel C Analog Delay Test": "Analog delay test for C failed.",
"Failed Channel D Analog Delay Test": "Analog delay test for D failed.",
"Failed Channel E Analog Delay Test": "Analog delay test for E failed.",
"Failed Channel F Analog Delay Test": "Analog delay test for F failed.",
"Failed Channel G Analog Delay Test": "Analog delay test for G failed.",
"Failed Channel H Analog Delay Test": "Analog delay test for H failed.",
"Failed Sample and Hold Calibration": "Auto calibration of sample and hold failed.",
"Failed T0 Calibration": "Auto calibration of channel T0 failed.",
"Failed T1 Calibration": "Auto calibration of channel T1 failed.",
"Failed A Calibration": "Auto calibration of channel A failed.",
"Failed B Calibration": "Auto calibration of channel B failed.",
"Failed C Calibration": "Auto calibration of channel C failed.",
"Failed D Calibration": "Auto calibration of channel D failed.",
"Failed E Calibration": "Auto calibration of channel E failed.",
"Failed F Calibration": "Auto calibration of channel F failed.",
"Failed G Calibration": "Auto calibration of channel G failed.",
"Failed H Calibration": "Auto calibration of channel H failed.",
"Failed Vjitter Calibration": "Auto calibration of Vjitter failed.",
"Illegal Command": "The command syntax used was illegal.",
"Undefined Command": "The specified command does not exist.",
"Illegal Query": "The specified command does not permit queries.",
"Illegal Set": "The specified command can only be queried.",
"Null Parameter": "The parser detected an empty parameter.",
"Extra Parameters": "Too many parameters were provided.",
"Missing Parameters": "Some required parameters are missing.",
"Parameter Overflow": "Buffer overflow while parsing parameters.",
"Invalid Floating Point Number": "Expected a float but couldn't parse it.",
"Invalid Integer": "Expected an integer but couldn't parse it.",
"Integer Overflow": "Parsed integer is too large.",
"Invalid Hexadecimal": "Failed to parse expected hexadecimal input.",
"Syntax Error": "The parser detected a syntax error.",
"Communication Error": "Framing or parity error detected.",
"Over run": "Input buffer overflowed.",
"Too Many Errors": "Error buffer is full; some errors dropped.",
}

View File

@@ -1,381 +0,0 @@
import enum
import os
import threading
import time
from typing import Any
import numpy as np
from bec_lib.logger import bec_logger
from ophyd import ADComponent as ADCpt
from ophyd import Device, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV
from ophyd_devices.interfaces.base_classes.psi_detector_base import (
CustomDetectorMixin,
PSIDetectorBase,
)
from std_daq_client import StdDaqClient
logger = bec_logger.logger
class EigerError(Exception):
"""Base class for exceptions in this module."""
class EigerTimeoutError(EigerError):
"""Raised when the Eiger does not respond in time."""
class Eiger9MSetup(CustomDetectorMixin):
"""Eiger setup class
Parent class: CustomDetectorMixin
"""
def __init__(self, *args, parent: Device = None, **kwargs) -> None:
super().__init__(*args, parent=parent, **kwargs)
self.std_rest_server_url = (
kwargs["file_writer_url"] if "file_writer_url" in kwargs else "http://xbl-daq-29:5000"
)
self.std_client = None
self._lock = threading.RLock()
def on_init(self) -> None:
"""Initialize the detector"""
self.initialize_default_parameter()
self.initialize_detector()
self.initialize_detector_backend()
def initialize_detector(self) -> None:
"""Initialize detector"""
self.stop_detector()
self.parent.cam.trigger_mode.put(TriggerSource.GATING)
def initialize_default_parameter(self) -> None:
"""Set default parameters for Eiger9M detector"""
self.update_readout_time()
def update_readout_time(self) -> None:
"""Set readout time for Eiger9M detector"""
readout_time = (
self.parent.scaninfo.readout_time
if hasattr(self.parent.scaninfo, "readout_time")
else self.parent.MIN_READOUT
)
self.parent.readout_time = max(readout_time, self.parent.MIN_READOUT)
def initialize_detector_backend(self) -> None:
"""Initialize detector backend"""
self.std_client = StdDaqClient(url_base=self.std_rest_server_url)
self.std_client.stop_writer()
eacc = self.parent.scaninfo.username
self.update_std_cfg("writer_user_id", int(eacc.strip(" e")))
signal_conditions = [(lambda: self.std_client.get_status()["state"], "READY")]
if not self.wait_for_signals(
signal_conditions=signal_conditions,
timeout=self.parent.TIMEOUT_FOR_SIGNALS,
all_signals=True,
):
raise EigerTimeoutError(
f"Std client not in READY state, returns: {self.std_client.get_status()}"
)
def update_std_cfg(self, cfg_key: str, value: Any) -> None:
"""
Update std_daq config
Checks that the new value matches the type of the former entry.
Args:
cfg_key (str) : config key of value to be updated
value (Any) : value to be updated for the specified key
Raises:
Raises EigerError if the key was not in the config before and if the new value does not match the type of the old value
"""
cfg = self.std_client.get_config()
old_value = cfg.get(cfg_key)
if old_value is None:
raise EigerError(
f"Tried to change entry for key {cfg_key} in std_config that does not exist"
)
if not isinstance(value, type(old_value)):
raise EigerError(
f"Type of new value {type(value)}:{value} does not match old value"
f" {type(old_value)}:{old_value}"
)
cfg.update({cfg_key: value})
logger.debug(cfg)
self.std_client.set_config(cfg)
logger.debug(f"Updated std_daq config for key {cfg_key} from {old_value} to {value}")
def on_stage(self) -> None:
"""Prepare the detector for scan"""
self.prepare_detector()
self.prepare_data_backend()
self.publish_file_location(done=False, successful=False)
self.arm_acquisition()
def prepare_detector(self) -> None:
"""Prepare detector for scan"""
self.set_detector_threshold()
self.set_acquisition_params()
self.parent.cam.trigger_mode.put(TriggerSource.GATING)
def set_detector_threshold(self) -> None:
"""
Set the detector threshold
The function sets the detector threshold automatically to 1/2 of the beam energy.
"""
mokev = self.parent.device_manager.devices.mokev.obj.read()[
self.parent.device_manager.devices.mokev.name
]["value"]
factor = 1
unit = getattr(self.parent.cam.threshold_energy, "units", None)
if unit is not None and unit == "eV":
factor = 1000
setpoint = int(mokev * factor)
energy = self.parent.cam.beam_energy.read()[self.parent.cam.beam_energy.name]["value"]
if setpoint != energy:
self.parent.cam.beam_energy.set(setpoint)
threshold = self.parent.cam.threshold_energy.read()[self.parent.cam.threshold_energy.name][
"value"
]
if not np.isclose(setpoint / 2, threshold, rtol=0.05):
self.parent.cam.threshold_energy.set(setpoint / 2)
def set_acquisition_params(self) -> None:
"""Set acquisition parameters for the detector"""
self.parent.cam.num_images.put(
int(self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger)
)
self.parent.cam.num_frames.put(1)
self.update_readout_time()
def prepare_data_backend(self) -> None:
"""Prepare the data backend for the scan"""
self.parent.filepath.set(
self.parent.filewriter.compile_full_filename(f"{self.parent.name}.h5")
).wait()
self.filepath_exists(self.parent.filepath.get())
self.stop_detector_backend()
try:
self.std_client.start_writer_async(
{
"output_file": self.parent.filepath.get(),
"n_images": int(
self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger
),
}
)
except Exception as exc:
time.sleep(5)
if self.std_client.get_status()["state"] == "READY":
raise EigerTimeoutError(f"Timeout of start_writer_async with {exc}") from exc
signal_conditions = [
(lambda: self.std_client.get_status()["acquisition"]["state"], "WAITING_IMAGES")
]
if not self.wait_for_signals(
signal_conditions=signal_conditions,
timeout=self.parent.TIMEOUT_FOR_SIGNALS,
check_stopped=False,
all_signals=True,
):
raise EigerTimeoutError(
"Timeout of 5s reached for std_daq start_writer_async with std_daq client status"
f" {self.std_client.get_status()}"
)
def on_unstage(self) -> None:
"""Unstage the detector"""
pass
def on_complete(self) -> None:
"""Complete the detector"""
self.finished(timeout=self.parent.TIMEOUT_FOR_SIGNALS)
self.publish_file_location(done=True, successful=True)
def on_stop(self) -> None:
"""Stop the detector"""
self.stop_detector()
self.stop_detector_backend()
def stop_detector(self) -> None:
"""Stop the detector"""
# Stop detector
self.parent.cam.acquire.put(0)
signal_conditions = [
(
lambda: self.parent.cam.detector_state.read()[self.parent.cam.detector_state.name][
"value"
],
DetectorState.IDLE,
)
]
if not self.wait_for_signals(
signal_conditions=signal_conditions,
timeout=self.parent.TIMEOUT_FOR_SIGNALS - self.parent.TIMEOUT_FOR_SIGNALS // 2,
check_stopped=True,
all_signals=False,
):
# Retry stop detector and wait for remaining time
self.parent.cam.acquire.put(0)
if not self.wait_for_signals(
signal_conditions=signal_conditions,
timeout=self.parent.TIMEOUT_FOR_SIGNALS - self.parent.TIMEOUT_FOR_SIGNALS // 2,
check_stopped=True,
all_signals=False,
):
raise EigerTimeoutError(
f"Failed to stop detector, detector state {signal_conditions[0][0]}"
)
def stop_detector_backend(self) -> None:
"""Close file writer"""
self.std_client.stop_writer()
def filepath_exists(self, filepath: str) -> None:
"""Check if filepath exists"""
signal_conditions = [(lambda: os.path.exists(os.path.dirname(filepath)), True)]
if not self.wait_for_signals(
signal_conditions=signal_conditions,
timeout=self.parent.TIMEOUT_FOR_SIGNALS,
check_stopped=False,
all_signals=True,
):
raise EigerError(f"Timeout of 3s reached for filepath {filepath}")
def arm_acquisition(self) -> None:
"""Arm Eiger detector for acquisition"""
self.parent.cam.acquire.put(1)
signal_conditions = [
(
lambda: self.parent.cam.detector_state.read()[self.parent.cam.detector_state.name][
"value"
],
DetectorState.RUNNING,
)
]
if not self.wait_for_signals(
signal_conditions=signal_conditions,
timeout=self.parent.TIMEOUT_FOR_SIGNALS,
check_stopped=True,
all_signals=False,
):
raise EigerTimeoutError(
f"Failed to arm the acquisition. Detector state {signal_conditions[0][0]}"
)
def finished(self, timeout: int = 5) -> None:
"""Check if acquisition is finished."""
with self._lock:
signal_conditions = [
(
lambda: self.parent.cam.acquire.read()[self.parent.cam.acquire.name]["value"],
DetectorState.IDLE,
),
(lambda: self.std_client.get_status()["acquisition"]["state"], "FINISHED"),
(
lambda: self.std_client.get_status()["acquisition"]["stats"][
"n_write_completed"
],
int(self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger),
),
]
if not self.wait_for_signals(
signal_conditions=signal_conditions,
timeout=timeout,
check_stopped=True,
all_signals=True,
):
raise EigerTimeoutError(
f"Reached timeout with detector state {signal_conditions[0][0]}, std_daq state"
f" {signal_conditions[1][0]} and received frames of {signal_conditions[2][0]} for"
" the file writer"
)
self.stop_detector()
self.stop_detector_backend()
class SLSDetectorCam(Device):
"""
SLS Detector Camera - Eiger9M
Base class to map EPICS PVs to ophyd signals.
"""
threshold_energy = ADCpt(EpicsSignalWithRBV, "ThresholdEnergy")
beam_energy = ADCpt(EpicsSignalWithRBV, "BeamEnergy")
bit_depth = ADCpt(EpicsSignalWithRBV, "BitDepth")
num_images = ADCpt(EpicsSignalWithRBV, "NumCycles")
num_frames = ADCpt(EpicsSignalWithRBV, "NumFrames")
trigger_mode = ADCpt(EpicsSignalWithRBV, "TimingMode")
trigger_software = ADCpt(EpicsSignal, "TriggerSoftware")
acquire = ADCpt(EpicsSignal, "Acquire")
detector_state = ADCpt(EpicsSignalRO, "DetectorState_RBV")
class TriggerSource(int, enum.Enum):
"""Trigger signals for Eiger9M detector"""
AUTO = 0
TRIGGER = 1
GATING = 2
BURST_TRIGGER = 3
class DetectorState(int, enum.Enum):
"""Detector states for Eiger9M detector"""
IDLE = 0
ERROR = 1
WAITING = 2
FINISHED = 3
TRANSMITTING = 4
RUNNING = 5
STOPPED = 6
STILL_WAITING = 7
INITIALIZING = 8
DISCONNECTED = 9
ABORTED = 10
class Eiger9McSAXS(PSIDetectorBase):
"""
Eiger9M detector for CSAXS
Parent class: PSIDetectorBase
class attributes:
custom_prepare_cls (FalconSetup) : Custom detector setup class for cSAXS,
inherits from CustomDetectorMixin
PSIDetectorBase.set_min_readout (float) : Minimum readout time for the detector
Various EpicsPVs for controlling the detector
"""
# Specify which functions are revealed to the user in BEC client
USER_ACCESS = []
# specify Setup class
custom_prepare_cls = Eiger9MSetup
# specify minimum readout time for detector and timeout for checks after unstage
MIN_READOUT = 3e-3
TIMEOUT_FOR_SIGNALS = 5
# specify class attributes
cam = ADCpt(SLSDetectorCam, "cam1:")
if __name__ == "__main__":
eiger = Eiger9McSAXS(name="eiger", prefix="X12SA-ES-EIGER9M:", sim_mode=True)

View File

@@ -1,15 +1,17 @@
"""Falcon Sitoro detector class for cSAXS beamline."""
import enum
import os
import threading
from typing import Literal
from bec_lib.file_utils import get_full_path
from bec_lib.logger import bec_logger
from ophyd import Component as Cpt
from ophyd import Device, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV
from ophyd.mca import EpicsMCARecord
from ophyd_devices.interfaces.base_classes.psi_detector_base import (
CustomDetectorMixin,
PSIDetectorBase,
)
from ophyd_devices import CompareStatus, FileEventSignal
from ophyd_devices.devices.areadetector.plugins import HDF5Plugin_V35 as HDF5Plugin
from ophyd_devices.devices.dxp import EpicsDXPFalcon, EpicsMCARecord, Falcon
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
logger = bec_logger.logger
@@ -18,15 +20,11 @@ class FalconError(Exception):
"""Base class for exceptions in this module."""
class FalconTimeoutError(FalconError):
"""Raised when the Falcon does not respond in time."""
class DetectorState(enum.IntEnum):
class ACQUIRESTATUS(enum.IntEnum):
"""Detector states for Falcon detector"""
DONE = 0
ACQUIRING = 1
ACQUIRING = 1 # or Capturing
class TriggerSource(enum.IntEnum):
@@ -44,238 +42,56 @@ class MappingSource(enum.IntEnum):
MAPPING = 1
class EpicsDXPFalcon(Device):
"""
DXP parameters for Falcon detector
class FalconControl(Falcon):
"""Falcon Control class at cSAXS. prefix: 'X12SA-SITORO:'"""
Base class to map EPICS PVs from DXP parameters to ophyd signals.
dxp = Cpt(EpicsDXPFalcon, "dxp1:")
mca = Cpt(EpicsMCARecord, "mca1")
hdf5 = Cpt(HDF5Plugin, "HDF1:")
class FalconcSAXS(PSIDeviceBase, FalconControl):
"""
Falcon Sitoro detector for CSAXS
class attributes:
dxp (EpicsDXPFalcon) : DXP parameters for Falcon detector
mca (EpicsMCARecord) : MCA parameters for Falcon detector
hdf5 (FalconHDF5Plugins) : HDF5 parameters for Falcon detector
MIN_READOUT (float) : Minimum readout time for the detector
"""
elapsed_live_time = Cpt(EpicsSignal, "ElapsedLiveTime")
elapsed_real_time = Cpt(EpicsSignal, "ElapsedRealTime")
elapsed_trigger_live_time = Cpt(EpicsSignal, "ElapsedTriggerLiveTime")
# specify minimum readout time for detector
MIN_READOUT = 3e-3
_pv_timeout = 3 # Timeout for PV operations in seconds
# Energy Filter PVs
energy_threshold = Cpt(EpicsSignalWithRBV, "DetectionThreshold")
min_pulse_separation = Cpt(EpicsSignalWithRBV, "MinPulsePairSeparation")
detection_filter = Cpt(EpicsSignalWithRBV, "DetectionFilter", string=True)
scale_factor = Cpt(EpicsSignalWithRBV, "ScaleFactor")
risetime_optimisation = Cpt(EpicsSignalWithRBV, "RisetimeOptimization")
# Misc PVs
detector_polarity = Cpt(EpicsSignalWithRBV, "DetectorPolarity")
decay_time = Cpt(EpicsSignalWithRBV, "DecayTime")
current_pixel = Cpt(EpicsSignalRO, "CurrentPixel")
class FalconHDF5Plugins(Device):
"""
HDF5 parameters for Falcon detector
Base class to map EPICS PVs from HDF5 Plugin to ophyd signals.
"""
capture = Cpt(EpicsSignalWithRBV, "Capture")
enable = Cpt(EpicsSignalWithRBV, "EnableCallbacks", string=True, kind="config")
xml_file_name = Cpt(EpicsSignalWithRBV, "XMLFileName", string=True, kind="config")
lazy_open = Cpt(EpicsSignalWithRBV, "LazyOpen", string=True, doc="0='No' 1='Yes'")
temp_suffix = Cpt(EpicsSignalWithRBV, "TempSuffix", string=True)
file_path = Cpt(EpicsSignalWithRBV, "FilePath", string=True, kind="config")
file_name = Cpt(EpicsSignalWithRBV, "FileName", string=True, kind="config")
file_template = Cpt(EpicsSignalWithRBV, "FileTemplate", string=True, kind="config")
num_capture = Cpt(EpicsSignalWithRBV, "NumCapture", kind="config")
file_write_mode = Cpt(EpicsSignalWithRBV, "FileWriteMode", kind="config")
queue_size = Cpt(EpicsSignalWithRBV, "QueueSize", kind="config")
array_counter = Cpt(EpicsSignalWithRBV, "ArrayCounter", kind="config")
class FalconSetup(CustomDetectorMixin):
"""
Falcon setup class for cSAXS
Parent class: CustomDetectorMixin
"""
def __init__(self, *args, parent: Device = None, **kwargs) -> None:
super().__init__(*args, parent=parent, **kwargs)
self._lock = threading.RLock()
file_event = Cpt(FileEventSignal, name="file_event")
def on_init(self) -> None:
"""Initialize Falcon detector"""
self.initialize_default_parameter()
self.initialize_detector()
self.initialize_detector_backend()
"""Initialize Falcon Sitoro detector"""
self._lock = threading.RLock()
self._readout_time = self.MIN_READOUT
self._value_pixel_per_buffer = 20
self._queue_size = 2000
self._full_path = ""
def initialize_default_parameter(self) -> None:
def on_connected(self):
"""
Set default parameters for Falcon
This will set:
- readout (float): readout time in seconds
- value_pixel_per_buffer (int): number of spectra in buffer of Falcon Sitoro
Setup Falcon Sitoro detector default parameters once signals are connected
"""
self.parent.value_pixel_per_buffer = 20
self.update_readout_time()
def update_readout_time(self) -> None:
"""Set readout time for Eiger9M detector"""
readout_time = (
self.parent.scaninfo.readout_time
if hasattr(self.parent.scaninfo, "readout_time")
else self.parent.MIN_READOUT
)
self.parent.readout_time = max(readout_time, self.parent.MIN_READOUT)
def initialize_detector(self) -> None:
"""Initialize Falcon detector"""
self.stop_detector()
self.stop_detector_backend()
self.on_stop()
self._initialize_detector()
self._initialize_detector_backend()
self.set_trigger(
mapping_mode=MappingSource.MAPPING, trigger_source=TriggerSource.GATE, ignore_gate=0
)
# 1 Realtime
self.parent.preset_mode.put(1)
# 0 Normal, 1 Inverted
self.parent.input_logic_polarity.put(0)
# 0 Manual 1 Auto
self.parent.auto_pixels_per_buffer.put(0)
# Sets the number of pixels/spectra in the buffer
self.parent.pixels_per_buffer.put(self.parent.value_pixel_per_buffer)
def initialize_detector_backend(self) -> None:
"""Initialize the detector backend for Falcon."""
self.parent.hdf5.enable.put(1)
# file location of h5 layout for cSAXS
self.parent.hdf5.xml_file_name.put("layout.xml")
# TODO Check if lazy open is needed and wanted!
self.parent.hdf5.lazy_open.put(1)
self.parent.hdf5.temp_suffix.put("")
# size of queue for number of spectra allowed in the buffer, if too small at high throughput, data is lost
self.parent.hdf5.queue_size.put(2000)
# Segmentation into Spectra within EPICS, 1 is activate, 0 is deactivate
self.parent.nd_array_mode.put(1)
def on_stage(self) -> None:
"""Prepare detector and backend for acquisition"""
self.prepare_detector()
self.prepare_data_backend()
self.publish_file_location(done=False, successful=False)
self.arm_acquisition()
def prepare_detector(self) -> None:
"""Prepare detector for acquisition"""
self.set_trigger(
mapping_mode=MappingSource.MAPPING, trigger_source=TriggerSource.GATE, ignore_gate=0
)
self.parent.preset_real.put(self.parent.scaninfo.exp_time)
self.parent.pixels_per_run.put(
int(self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger)
)
def prepare_data_backend(self) -> None:
"""Prepare data backend for acquisition"""
self.parent.filepath.set(
self.parent.filewriter.compile_full_filename(f"{self.parent.name}.h5")
).wait()
file_path, file_name = os.path.split(self.parent.filepath.get())
self.parent.hdf5.file_path.put(file_path)
self.parent.hdf5.file_name.put(file_name)
self.parent.hdf5.file_template.put("%s%s")
self.parent.hdf5.num_capture.put(
int(self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger)
)
self.parent.hdf5.file_write_mode.put(2)
# Reset spectrum counter in filewriter, used for indexing & identifying missing triggers
self.parent.hdf5.array_counter.put(0)
# Start file writing
self.parent.hdf5.capture.put(1)
def arm_acquisition(self) -> None:
"""Arm detector for acquisition"""
self.parent.start_all.put(1)
signal_conditions = [
(
lambda: self.parent.state.read()[self.parent.state.name]["value"],
DetectorState.ACQUIRING,
)
]
if not self.wait_for_signals(
signal_conditions=signal_conditions,
timeout=self.parent.TIMEOUT_FOR_SIGNALS,
check_stopped=True,
all_signals=False,
):
raise FalconTimeoutError(
f"Failed to arm the acquisition. Detector state {signal_conditions[0][0]}"
)
def on_unstage(self) -> None:
"""Unstage detector and backend"""
pass
def on_complete(self) -> None:
"""Complete detector and backend"""
self.finished(timeout=self.parent.TIMEOUT_FOR_SIGNALS)
self.publish_file_location(done=True, successful=True)
def on_stop(self) -> None:
"""Stop detector and backend"""
self.stop_detector()
self.stop_detector_backend()
def stop_detector(self) -> None:
"""Stops detector"""
self.parent.stop_all.put(1)
self.parent.erase_all.put(1)
signal_conditions = [
(lambda: self.parent.state.read()[self.parent.state.name]["value"], DetectorState.DONE)
]
if not self.wait_for_signals(
signal_conditions=signal_conditions,
timeout=self.parent.TIMEOUT_FOR_SIGNALS - self.parent.TIMEOUT_FOR_SIGNALS // 2,
all_signals=False,
):
# Retry stop detector and wait for remaining time
raise FalconTimeoutError(
f"Failed to stop detector, timeout with state {signal_conditions[0][0]}"
)
def stop_detector_backend(self) -> None:
"""Stop the detector backend"""
self.parent.hdf5.capture.put(0)
def finished(self, timeout: int = 5) -> None:
"""Check if scan finished succesfully"""
with self._lock:
total_frames = int(
self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger
)
signal_conditions = [
(self.parent.dxp.current_pixel.get, total_frames),
(self.parent.hdf5.array_counter.get, total_frames),
]
if not self.wait_for_signals(
signal_conditions=signal_conditions,
timeout=timeout,
check_stopped=True,
all_signals=True,
):
logger.debug(
f"Falcon missed a trigger: received trigger {self.parent.dxp.current_pixel.get()},"
f" send data {self.parent.hdf5.array_counter.get()} from total_frames"
f" {total_frames}"
)
self.stop_detector()
self.stop_detector_backend()
def set_trigger(
self, mapping_mode: MappingSource, trigger_source: TriggerSource, ignore_gate: int = 0
self,
mapping_mode: MappingSource,
trigger_source: TriggerSource,
ignore_gate: Literal[0, 1] = 0,
) -> None:
"""
Set triggering mode for detector
@@ -287,63 +103,140 @@ class FalconSetup(CustomDetectorMixin):
"""
mapping = int(mapping_mode)
trigger = trigger_source
self.parent.collect_mode.put(mapping)
self.parent.pixel_advance_mode.put(trigger)
self.parent.ignore_gate.put(ignore_gate)
trigger = int(trigger_source)
self.collect_mode.put(mapping)
self.pixel_advance_mode.put(trigger)
self.ignore_gate.put(ignore_gate)
def _initialize_detector(self) -> None:
"""Initialize Falcon detector"""
class FalconcSAXS(PSIDetectorBase):
"""
Falcon Sitoro detector for CSAXS
# 1 Realtime
self.preset_mode.put(1)
Parent class: PSIDetectorBase
# 0 Normal, 1 Inverted
self.input_logic_polarity.put(0)
class attributes:
custom_prepare_cls (FalconSetup) : Custom detector setup class for cSAXS,
inherits from CustomDetectorMixin
PSIDetectorBase.set_min_readout (float) : Minimum readout time for the detector
dxp (EpicsDXPFalcon) : DXP parameters for Falcon detector
mca (EpicsMCARecord) : MCA parameters for Falcon detector
hdf5 (FalconHDF5Plugins) : HDF5 parameters for Falcon detector
MIN_READOUT (float) : Minimum readout time for the detector
"""
# 0 Manual 1 Auto
self.auto_pixels_per_buffer.put(0)
# Specify which functions are revealed to the user in BEC client
USER_ACCESS = ["describe"]
# Sets the number of pixels/spectra in the buffer
self.pixels_per_buffer.put(self._value_pixel_per_buffer)
# specify Setup class
custom_prepare_cls = FalconSetup
# specify minimum readout time for detector
MIN_READOUT = 3e-3
TIMEOUT_FOR_SIGNALS = 5
def _initialize_detector_backend(self) -> None:
"""Initialize the detector backend for Falcon."""
# Enable HDF5 plugin
self.hdf5.enable.put(1)
# specify class attributes
dxp = Cpt(EpicsDXPFalcon, "dxp1:")
mca = Cpt(EpicsMCARecord, "mca1")
hdf5 = Cpt(FalconHDF5Plugins, "HDF1:")
# Use layout.xml file for cSAXS Falcon. FIXME:Should be checked if IOC runs on different host.
self.hdf5.xml_file_name.put("layout.xml")
stop_all = Cpt(EpicsSignal, "StopAll")
erase_all = Cpt(EpicsSignal, "EraseAll")
start_all = Cpt(EpicsSignal, "StartAll")
state = Cpt(EpicsSignal, "Acquiring")
preset_mode = Cpt(EpicsSignal, "PresetMode") # 0 No preset 1 Real time 2 Events 3 Triggers
preset_real = Cpt(EpicsSignal, "PresetReal")
preset_events = Cpt(EpicsSignal, "PresetEvents")
preset_triggers = Cpt(EpicsSignal, "PresetTriggers")
triggers = Cpt(EpicsSignalRO, "MaxTriggers", lazy=True)
events = Cpt(EpicsSignalRO, "MaxEvents", lazy=True)
input_count_rate = Cpt(EpicsSignalRO, "MaxInputCountRate", lazy=True)
output_count_rate = Cpt(EpicsSignalRO, "MaxOutputCountRate", lazy=True)
collect_mode = Cpt(EpicsSignal, "CollectMode") # 0 MCA spectra, 1 MCA mapping
pixel_advance_mode = Cpt(EpicsSignal, "PixelAdvanceMode")
ignore_gate = Cpt(EpicsSignal, "IgnoreGate")
input_logic_polarity = Cpt(EpicsSignal, "InputLogicPolarity")
auto_pixels_per_buffer = Cpt(EpicsSignal, "AutoPixelsPerBuffer")
pixels_per_buffer = Cpt(EpicsSignal, "PixelsPerBuffer")
pixels_per_run = Cpt(EpicsSignal, "PixelsPerRun")
nd_array_mode = Cpt(EpicsSignal, "NDArrayMode")
# TODO Check if lazy open is needed and wanted!
self.hdf5.lazy_open.put(1)
self.hdf5.temp_suffix.put("")
# Size of the queue for the number of spectra allowed in the buffer. If too small, data is lost at high throughput
self.hdf5.queue_size.put(self._queue_size)
self.hdf5.file_template.put("%s%s")
self.hdf5.file_write_mode.put(2)
# Set nd_array mode to 1: This means segmentation into Spectra within EPICS, 1 is activate, 0 is deactivate
self.nd_array_mode.put(1)
def on_stage(self):
"""
This method is called when the detector is staged for acquisition.
We use the information in scan_info.msg about the upcoming scan to set all relevant parameters on the detector.
"""
# Calculate relevant parameters
num_points = self.scan_info.msg.num_points
frames_per_trigger = self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1)
overall_frames = int(num_points * frames_per_trigger)
exp_time = self.scan_info.msg.scan_parameters["exp_time"]
self._full_path = get_full_path(self.scan_info.msg, self.name)
# Check that exposure time is larger than readout time
readout_time = max(
self.scan_info.msg.scan_parameters.get("readout_time", self.MIN_READOUT),
self.MIN_READOUT,
)
if exp_time < readout_time:
raise ValueError(
f"Exposure time {exp_time} is less than minimum readout time {readout_time}"
)
# TODO: Add h5_entries for linking the Falcon NEXUS entries with the master file
self.file_event.put(file_path=self._full_path, done=False, successful=False)
self.preset_real_time.put(exp_time)
self.pixels_per_run.put(overall_frames)
# Prepare detector backend PVs
file_path, file_name = os.path.split(self._full_path)
self.hdf5.file_path.put(file_path)
self.hdf5.file_name.put(file_name)
self.hdf5.num_capture.put(overall_frames)
# Reset spectrum counter in filewriter, used for indexing & identifying missing triggers
self.hdf5.array_counter.put(0)
# Start file writing
self.hdf5.capture.put(1)
# Start the acquisition
self.start_all.put(1)
def on_pre_scan(self):
"""
Method for actions just before the scan starts.
"""
status_camera = CompareStatus(
self.acquire_busy, ACQUIRESTATUS.ACQUIRING, timeout=self._pv_timeout
)
status_writer = CompareStatus(
self.hdf5.capture, ACQUIRESTATUS.ACQUIRING, timeout=self._pv_timeout
)
# Logical combine of statuses
status = status_camera & status_writer
self.cancel_on_stop(status)
return status
def _complete_callback(self, status: CompareStatus) -> None:
"""Callback for when the device completes a scan."""
# FIXME Add proper h5 entries once checked
if status.success:
self.file_event.put(
file_path=self._full_path, # pylint: disable:protected-access
done=True,
successful=True,
)
else:
self.file_event.put(
file_path=self._full_path, # pylint: disable:protected-access
done=True,
successful=False,
)
def on_complete(self) -> None:
"""Complete detector and backend"""
# Calculate relevant parameters
num_points = self.scan_info.msg.num_points
frames_per_trigger = self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1)
overall_frames = int(num_points * frames_per_trigger)
status_detector = CompareStatus(self.dxp.current_pixel, overall_frames, run=True)
status_backend = CompareStatus(self.hdf5.array_counter, overall_frames, run=True)
status = status_detector & status_backend
self.cancel_on_stop(status)
status.add_callback(self._complete_callback)
return status
def on_stop(self) -> None:
"""Stop detector and backend"""
self.stop_all.put(1)
self.hdf5.capture.put(0)
self.erase_all.put(1)
if __name__ == "__main__":
falcon = FalconcSAXS(name="falcon", prefix="X12SA-SITORO:", sim_mode=True)
falcon = FalconcSAXS(name="falcon", prefix="X12SA-SITORO:")

View File

@@ -0,0 +1,13 @@
# MCS Card implementation at the CSAXS beamline
This module provides an ophyd device implementation for the SIS3820 Multi-Channel Scaler (MCS) card, used at the cSAXS beamline for time-resolved data acquisition. It interfaces with the EPICS IOC for the SIS3820 MCS card.
Information about the EPICS driver can be found here (https://millenia.cars.aps.anl.gov/software/epics/mcaStruck.html).
# Important Notes
Operation of the MCS card requires proper configuration as some of the parameters are interdependent. In addition, empirical adjustments have been found to be necessary for optimal performance at the beamline. In its current implementation, comments about these dependencies are highlighted in the source code of the ophyd device classes [MCSCard](./mcs_card.py) and [MCSCardCSAXS](./mcs_card_csaxs.py). It is highly recommended to review these comments before refactoring, modifying, or extending the code.
## Ophyd Device Implementation
The ophyd device implementation is provided [MCSCard](./mcs_card.py). This class provides a basic interface to the MCS PVs, including configuration of parameters such as number of channels, dwell time, and control of acquisition start/stop. Please check the source code of the class for more details of the implementation.
The [MCSCardCSAXS](./mcs_card_csaxs.py) class extends the basic MCSCard implementation with cSAXS-specific logic and configurations. Please be aware that this is also linked to the implementation of other devices, most notably the [delay generator integration](../delay_generator_csaxs/README.md), which is used as the trigger source for the MCS card during operation.

View File

@@ -0,0 +1 @@
from .mcs_card import MCSCard

View File

@@ -0,0 +1,344 @@
"""
EPICS SIS38XX Multichannel Scaler (MCS) Interface
This module provides an interface to the SIS3801/SIS3820 multichannel scaler (MCS) cards via EPICS.
It focuses on the implementation for the SIS3820 model, as input/output modes differ between SIS3801
and SIS3820. It supports both MCS and scaler record operations, enabling configuration and control of
acquisition parameters such as dwell time, channel advance mode, and input/output settings.
The module facilitates data acquisition by managing FIFO buffers and simulating conventional
MCS behavior through memory buffers.
At cSAXS, the SIS3820 model is used, which supports 32 channels.
References:
- EPICS SIS3801 and SIS3820 Drivers: https://millenia.cars.aps.anl.gov/software/epics/mcaStruck.html
"""
from __future__ import annotations
import enum
from ophyd import Component as Cpt
from ophyd import Device, DynamicDeviceComponent, EpicsSignal, EpicsSignalRO, Kind
class CHANNELADVANCE(int, enum.Enum):
"""Channel advance pixel mode for MCS card."""
INTERNAL = 0
EXTERNAL = 1
class ACQUIRING(int, enum.Enum):
"""Acquisition status for MCS card."""
DONE = 0
ACQUIRING = 1
class READMODE(int, enum.Enum):
"""Read mode for MCS channels."""
PASSIVE = 0
EVENT = 1
IO_INTR = 2
FREQ_0_1HZ = 3
FREQ_0_2HZ = 4
FREQ_0_5HZ = 5
FREQ_1HZ = 6
FREQ_2HZ = 7
FREQ_5HZ = 8
FREQ_10HZ = 9
FREQ_100HZ = 10
class CHANNEL1SOURCE(int, enum.Enum):
"""Source for first counter pulses."""
INTERNAL_CLOCK = 0
EXTERNAL = 1
class POLARITY(int, enum.Enum):
"""Polarity of input_polarity/output_polarity for MCS card."""
NORMAL = 0
INVERTED = 1
class ACQUIREMODE(int, enum.Enum):
"""Acquire mode for the card. Allowed modes are Scaler and MCS."""
MCS = 0
SCALER = 1
class MODELS(int, enum.Enum):
SIS3801 = 0
SIS3820 = 1
class INPUTMODE(int, enum.Enum):
"""SIS3820 input mode definitions, in total there are 8 modes (0-7).
Each mode defines the function of external inputs 1-4.
Note: SIS3820 has extended input modes compared to SIS3801.
Please check the EPICS documentation for details on the specific input modes supported by SIS3801.
"""
MODE_0 = 0
MODE_1 = 1
MODE_2 = 2
MODE_3 = 3
MODE_4 = 4
MODE_5 = 5
MODE_6 = 6
MODE_7 = 7
def describe(self) -> str:
"""Return a description of the input mode."""
descriptions = {
self.MODE_0: "Inputs 1-4: No function (default idle mode)",
self.MODE_1: "Inputs 1-4: Next pulse, User bit 1, User bit 2, Inhibit next pulse",
self.MODE_2: "Inputs 1-4: Next pulse, User bit 1, Inhibit counting, Inhibit next pulse",
self.MODE_3: "Inputs 1-4: Next pulse, User bit 1, User bit 2, Inhibit counting",
self.MODE_4: "Inputs 1-4: Inhibit counting channels 1-8, 9-16, 17-24, 25-32",
self.MODE_5: "Inputs 1-4: Next pulse, HISCAL_START, No function, No function",
self.MODE_6: "Inputs 1-4: Next pulse, Inhibit counting, Clear counters, User bit 1",
self.MODE_7: "Inputs 1-4: Encoder A, Encoder B, Encoder I, Inhibit counting",
}
return descriptions.get(self, "Unknown input mode")
class OUTPUTMODE(int, enum.Enum):
"""SIS3820 output mode definitions, in total there are 4 modes (0-3).
Each mode configures output signals 5-8.
Note: SIS3820 supports 4 output modes (0-3), SIS3801 supports only Mode 0 with differen functionality.
Please check the EPICS documentation for details on the specific output modes supported by SIS3801.
"""
MODE_0 = 0
MODE_1 = 1
MODE_2 = 2
MODE_3 = 3
def describe(self) -> str:
"""Return a description of the output mode."""
descriptions = {
self.MODE_0: "Outputs 5-8: LNE/CIP, SDRAM empty, SDRAM threshold, User LED",
self.MODE_1: "Outputs 5-8: LNE/CIP, Enabled, 50 MHz, User LED",
self.MODE_2: "Outputs 5-8: LNE/CIP, 10 MHz (20ns), 10 MHz (20ns), User LED",
self.MODE_3: "Outputs 5-8: LNE/CIP, 10 MHz (20ns), MUX OUT channel, User LED (requires firmware ≥ 0x10A)",
}
return descriptions.get(self, "Unknown output mode")
def _create_mca_channels(num_channels: int) -> dict[str, tuple]:
"""
Create a dictionary of MCA channel definitions for the DynamicDeviceComponent.
Starts from channel 1 to num_channels.
Args:
num_channels (int): The number of MCA channels to create.
"""
mcs_channels = {}
for i in range(1, num_channels + 1):
mcs_channels[f"mca{i}"] = (
EpicsSignalRO,
f"mca{i}.VAL",
{"kind": Kind.omitted, "auto_monitor": True, "doc": f"MCA channel {i}."},
)
return mcs_channels
class MCSCard(Device):
"""
Ophyd implementation for the interface to the SIS3801/SIS3820 multichannel scaler (MCS) cards via EPICS.
This class provides signals to expose EPICS PVs of the MCS card. More details can be found in the
documentation of the EPICS drivers for SIS3801 and SIS3820.
References:
- EPICS SIS3801 and SIS3820 Drivers: https://millenia.cars.aps.anl.gov/software/epics/mcaStruck.html
"""
snl_connected = Cpt(
EpicsSignalRO,
"SNL_Connected",
kind=Kind.omitted,
doc="Indicates whether the SNL program has connected to all PVs.",
)
# NOTE: Please note that the erase_all command sends the mca or waveform records to process after erasing, potentially also values of 0. This logic needs to be considered when running callbacks on the mca channels.
erase_all = Cpt(
EpicsSignal,
"EraseAll",
kind=Kind.omitted,
doc="Erases all mca or waveform records, setting elapsed times and counts in all channels to 0. Please note that this operation sends the mca or waveform records to process after erasing, potentially also 0s.",
)
erase_start = Cpt(
EpicsSignal,
"EraseStart",
kind=Kind.omitted,
doc="Erases all mca or waveform records and starts acquisition.",
)
start_all = Cpt(
EpicsSignal,
"StartAll",
kind=Kind.omitted,
doc="Starts or resumes acquisition without erasing first.",
)
acquiring = Cpt(
EpicsSignalRO,
"Acquiring",
kind=Kind.omitted,
auto_monitor=True,
doc="Acquiring (=1) when acquisition is in progress and Done (=0) when acquisition is complete.",
)
stop_all = Cpt(EpicsSignal, "StopAll", kind=Kind.omitted, doc="Stops acquisition.")
preset_real = Cpt(
EpicsSignal,
"PresetReal",
kind=Kind.omitted,
doc="Preset real time. If non-zero then acquisition will stop when this time is reached.",
)
elapsed_real = Cpt(
EpicsSignalRO,
"ElapsedReal",
kind=Kind.omitted,
doc="Elapsed time since acquisition started.",
)
read_all = Cpt(
EpicsSignal,
"DoReadAll.VAL",
kind=Kind.omitted,
doc="Forces a read of all mca or waveform records from the hardware. This record can be set to periodically process to update the records during acquisition. Note that even if this record has SCAN=Passive the mca or waveform records will always process once when acquisition completes.",
)
read_mode = Cpt(
EpicsSignal,
"ReadAll.SCAN",
kind=Kind.omitted,
doc="Readout mode for transferring data from FIFO buffer to mca EPICS scalars.",
)
num_use_all = Cpt(
EpicsSignal,
"NuseAll",
kind=Kind.omitted,
doc="The number of channels to use for the mca or waveform records. Acquisition will automatically stop when the number of channel advances reaches this value.",
)
dwell = Cpt(
EpicsSignal,
"Dwell",
kind=Kind.omitted,
doc="The dwell time per channel when using internal channel advance mode.",
)
channel_advance = Cpt(
EpicsSignal,
"ChannelAdvance",
kind=Kind.omitted,
doc="The channel advance mode. Choices are 'Internal' (count for a preset time per channel) or 'External' (advance on external hardware channel advance signal).",
)
count_on_start = Cpt(
EpicsSignal,
"CountOnStart",
kind=Kind.omitted,
doc="Flag controlling whether the module begins counting immediately when acquisition starts. This record only applies in External channel advance mode. If No (=0) then counting does not start in channel 0 until receipt of the first external channel advance pulse. If Yes (=1) then counting in channel 0 starts immediately when acquisition starts, without waiting for the first external channel advance pulse.",
)
software_channel_advance = Cpt(
EpicsSignal,
"SoftwareChannelAdvance",
kind=Kind.omitted,
doc="Processing this record causes a channel advance to occur immediately, without waiting for the current dwell time to be reached or the next external channel advance pulse to arrive.",
)
channel1_source = Cpt(
EpicsSignal,
"Channel1Source",
kind=Kind.omitted,
doc="Controls the source of pulses into the first counter. The choices are 'Int. clock' which selects the internal clock, and 'External' which selects the external pulse input to counter 1.",
)
prescale = Cpt(
EpicsSignal,
"Prescale",
kind=Kind.omitted,
doc="The prescale factor for external channel advance pulses. If the prescale factor is N then N external channel advance pulses must be received before a channel advance will occur.",
)
enable_client_wait = Cpt(
EpicsSignal,
"EnableClientWait",
kind=Kind.omitted,
doc="Flag to force acquisition to wait until a client clears the ClientWait busy record before proceeding to the next acquisition. This can be useful with the scan record.",
)
client_wait = Cpt(
EpicsSignal,
"ClientWait",
kind=Kind.omitted,
doc="Flag that will be set to 1 when acquisition completes, and which a client must set back to 0 to allow acquisition to proceed. This only has an effect if EnableClientWait is 1.",
)
acquire_mode = Cpt(
EpicsSignal,
"AcquireMode",
kind=Kind.omitted,
doc="The current acquisition mode (MCS=0 or Scaler=1). This record is used to turn off the scaler record Autocount in MCS mode.",
)
# NOTE: Setting mux_output programmatically results in occasional errors on the IOC; it is recommended to avoid using it.
mux_output = Cpt(
EpicsSignal,
"MUXOutput",
kind=Kind.omitted,
doc="Value of 0-32 used to select which input signal is routed to output signal 7 on the SIS3820 in output mode 3. NOTE: This settings seems to occasionally result in errors on the IOC; it is recommended to avoid using it.",
)
user_led = Cpt(
EpicsSignal,
"UserLED",
kind=Kind.omitted,
doc="Toggles the user LED and also output signal 8 on the SIS3820.",
)
input_mode = Cpt(
EpicsSignal,
"InputMode",
kind=Kind.omitted,
doc="The input mode. Supported input modes vary for SIS3801 and SIS3820.",
)
input_polarity = Cpt(
EpicsSignal,
"InputPolarity",
kind=Kind.omitted,
doc="The polarity of the input control signals on the SIS3820. Choices are Normal and Inverted.",
)
output_mode = Cpt(
EpicsSignal,
"OutputMode",
kind=Kind.omitted,
doc="The output mode. Supported output modes vary for SIS3801 and SIS3820.",
)
output_polarity = Cpt(
EpicsSignal,
"OutputPolarity",
kind=Kind.omitted,
doc="The polarity of the output control signals on the SIS3820. Choices are Normal and Inverted.",
)
model = Cpt(
EpicsSignalRO,
"Model",
kind=Kind.omitted,
doc="The scaler model. Values are 'SIS3801' and 'SIS3820'.",
)
firmware = Cpt(EpicsSignalRO, "Firmware", kind=Kind.omitted, doc="The firmware version.")
max_channels = Cpt(
EpicsSignalRO, "MaxChannels", kind=Kind.omitted, doc="The maximum number of channels."
)
# Relevant counters
current_channel = Cpt(
EpicsSignalRO,
"CurrentChannel",
kind=Kind.omitted,
auto_monitor=True,
doc="The current channel number, i.e. the number of channel advances that have occurred minus 1.",
)
counters = DynamicDeviceComponent(
_create_mca_channels(32),
kind=Kind.omitted,
doc="Sub-device with the mca counters 1-32 for SIS3820.",
)

View File

@@ -0,0 +1,538 @@
"""
Module for the MCSCard CSAXS implementation at cSAXS.
Please respect the comments regarding timing and procedures of the MCS card. These
are highlighted with NOTE comments directly in the code, indicating requirements
for stable device operation. Most of these constraints were identified
empirically through extensive testing with the SIS3820 MCS card IOC and are intended
to prevent unexpected hardware or IOC behavior.
"""
from __future__ import annotations
import threading
import time
import traceback
from contextlib import contextmanager
from functools import partial
from threading import RLock
from typing import TYPE_CHECKING, Callable, Literal
import numpy as np
from bec_lib.logger import bec_logger
from ophyd import Component as Cpt
from ophyd import EpicsSignalRO, Kind
from ophyd_devices import AsyncMultiSignal, CompareStatus, ProgressSignal, StatusBase
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from csaxs_bec.devices.epics.mcs_card.mcs_card import (
ACQUIREMODE,
ACQUIRING,
CHANNEL1SOURCE,
CHANNELADVANCE,
INPUTMODE,
OUTPUTMODE,
POLARITY,
READMODE,
MCSCard,
)
@contextmanager
def suppress_mca_callbacks(mcs_card: MCSCard, restore_after_timeout: None | float = None):
"""
Utility context manager to suppress MCA channel callbacks temporarily.
It is required because erasing all channels via 'erase_all' PV triggers
callbacks for each channel. Depending on timing, this can interfere with
ongoing data acquisition so this context manager can be used to suppress
those callbacks temporarily. If used with restore_after_timeout, the suppression
will be automatically cleared after the specified timeout in seconds.
NOTE: Please be aware that it does not restore previous state, which means
that _omit_mca_callbacks will remain set after exiting the context. It has
to be cleared manually if needed. This can be improved in the future, but
should be carefully coordinated with the logic implemented within '_on_counter_update'.
Args:
mcs_card (MCSCard): The MCSCard instance to suppress callbacks for.
restore_after_timeout (float | None): Optional timeout in seconds to automatically
clear the suppression after the specified time. If None, the original state
is not restored.
"""
mcs_card._omit_mca_callbacks.set() # pylint: disable=protected-access
try:
yield
finally:
if restore_after_timeout is not None:
time.sleep(restore_after_timeout)
mcs_card._omit_mca_callbacks.clear() # pylint: disable=protected-access
if TYPE_CHECKING: # pragma: no cover
from bec_lib.devicemanager import DeviceManagerBase, ScanInfo
logger = bec_logger.logger
class MCSCardCSAXS(PSIDeviceBase, MCSCard):
"""
Implementation of the MCSCard SIS3820 for CSAXS, prefix 'X12SA-MCS:'.
The basic functionality is inherited from the MCSCard class.
Please note that the number of channels is fixed to 32, so there will be data for all
32 channels. In addition, the logic of the card is linked to the timing system (DDG)
and therefore changes have to be coordinated with the logic on the DDG side.
Args:
name (str): Name of the device.
prefix (str, optional): Prefix for the EPICS PVs. Defaults to "".
"""
USER_ACCESS = ["mcs_recovery"]
# NOTE The number of MCA channels is fixed to 32 for the CSAXS MCS card.
# On the IOC, we receive a 'warning' or 'error' once we set this channel for the
# envisioned input/output mode settings of the card. However, we need to know the
# channels set as callback timing relies on the channels to be set.
# For the future, we may consider adding an initialization parameter to set
# the number of channels, which in return limits the number of subscriptions
# on the channels. However, mux_output should still be set to 32 on the IOC side.
# If this limits performance, this should be investigated with Controls engineers and
# the IOC.
NUM_MCA_CHANNELS: int = 32
# MCA counters for the card. Channels 1-32 will be sent to BEC.
mca = Cpt(
AsyncMultiSignal,
name="counters",
signals=[
f"mca{i}" for i in range(1, 33)
], # NOTE Channels 1-32, they need to be in sync with the 'counters' component (DynamicDeviceComponent) of the MCSCard
ndim=1,
async_update={"type": "add", "max_shape": [None]},
max_size=1000,
kind=Kind.normal,
doc=(
"AsyncMultiSignal for MCA card channels 1-32."
"Cabling of the MCS card determines which channel corresponds to which input."
),
)
progress = Cpt(ProgressSignal, doc="ProgressSignal indicating the progress of the device")
def __init__(
self,
name: str,
prefix: str = "",
scan_info: ScanInfo | None = None,
device_manager: DeviceManagerBase | None = None,
**kwargs,
):
super().__init__(
name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs
)
# NOTE MCS Clock frequency. This is linked to the settings of the SIS3820 IOC and
# cabeling of the card. Currently, the 'output_mode' is set to MODE_2 and one of the outputs
# 6 or 7 (both 10MHz clocks) is used on channel 5 input for the timing signal of the IOC.
# Please adjust this comment if the cabling or IOC settings change.
self._mcs_clock = 1e7 # 10MHz clock -> 1e7 Hz
self._pv_timeout = 2.0 # seconds
self._rlock = RLock()
# NOTE This parameter will be sent with async data of the mcs counters.
# Based on scan-paramters, e.g. frames_per_trigger, this will be either
# 'monitored' or 'burst_group'. This means whether data from this channel
# is in sync with monitored devices or another group. In this scenario,
# the other group is called burst_group. Other detectors connected and
# triggered through the same timing system should implement the same logic
# to allow data to be properly grouped afterwards.
self._acquisition_group: str = "monitored" # default value, will be updated in on_stage
self._num_total_triggers: int = 0
# Thread and event logic for monitoring async data emission after scan is done
# These are mostly internal variables for which values should not be changed externally.
# Adjusting the logic of them should also be handled with care and proper testing.
self._scan_done_thread_kill_event: threading.Event = threading.Event()
self._start_monitor_async_data_emission: threading.Event = threading.Event()
self._scan_done_callbacks: list[Callable[[], None]] = []
self._scan_done_thread: threading.Thread = threading.Thread(
target=self._monitor_async_data_emission, daemon=True
)
self._current_data_index: int = 0
self._mca_counter_index: int = 0
self._current_data: dict[str, dict[Literal["value", "timestamp"], list[int] | float]] = {}
self._omit_mca_callbacks: threading.Event = threading.Event()
def on_connected(self):
"""
This method is called once the device and all its PVs are connected. Any initial
setup of PVs should be managed here. Please be aware that settings of the MCS card
correlate with its operation mode, input/output modes, and timing. Changing single
parameters without understanding the overall logic may lead to unexpected behavior
of the device.Therefore, any modification of these parameters should be handled
with care and tested.
A brief summary of the procesdure that is implemented here:
- Stop any ongoing acquisiton.
- Setup the Initial initial settings of the MCS card with respective operation modes
- Run 'mcs_recovery' procedure to ensure that no pending acquisition data is scheduled
to be pushed through mcs channels
- Subscribe a callback '_on_counter_update' to mcs counter PVs to forward
data through AsyncMultiSignal to BEC
- Start the monitoring thread for async data emission after scan is done
"""
# NOTE Stop any ongoing acquisition first. This shut be done before setting any PVs.
self.stop_all.put(1)
#########################
### Setup MCS Card ###
#########################
# Setup the MCS card settings. Please note that any runtime modification
# these parameter may lead to unexpected behavior of the device.
# Therefore this has to be set up correctly.
self.channel_advance.set(CHANNELADVANCE.EXTERNAL).wait(timeout=self._pv_timeout)
self.channel1_source.set(CHANNEL1SOURCE.EXTERNAL).wait(timeout=self._pv_timeout)
self.prescale.set(1).wait(timeout=self._pv_timeout)
self.user_led.set(0).wait(timeout=self._pv_timeout)
# NOTE The number of output channels has to be set to NUM_MCA_CHANNELS.
# The logic to send data to BEC relies on knowing how many channels are active.
self.mux_output.put(self.NUM_MCA_CHANNELS)
# Set the input and output modes & polarities
self.input_mode.set(INPUTMODE.MODE_3).wait(timeout=self._pv_timeout)
self.input_polarity.set(POLARITY.NORMAL).wait(timeout=self._pv_timeout)
self.output_mode.set(OUTPUTMODE.MODE_2).wait(timeout=self._pv_timeout)
self.output_polarity.set(POLARITY.NORMAL).wait(timeout=self._pv_timeout)
self.count_on_start.set(0).wait(timeout=self._pv_timeout)
# NOTE Data is read out when the MCS card finishes an acquisition. The logic for this
# is also linked to triggering on the DDG.
# Set ReadMode to PASSIVE, the card will wait either wait for readout command or
# automatically readout once acquisition is done.
self.read_mode.set(READMODE.PASSIVE).wait(timeout=self._pv_timeout)
# Set the acquire mode
self.acquire_mode.set(ACQUIREMODE.MCS).wait(timeout=self._pv_timeout)
# Subscribe the progress signal
self.current_channel.subscribe(self._progress_update, run=False)
# NOTE: Run a recovery procedure to ensure that the card has no pending data
# that needs to be pushed through the mca channels. The procedure involves
# stopping any ongoing acquisition and erasing all data on the card. Including
# a short sleep to allow the IOC to process the commands.
self.mcs_recovery(timeout=1)
####################################
### Setup MCS Subscriptions ###
####################################
for sig in self.counters.component_names:
sig_obj: EpicsSignalRO = getattr(self.counters, sig)
sig_obj.subscribe(self._on_counter_update, run=False)
# Start monitoring thread
self._scan_done_thread.start()
def _on_counter_update(self, value: float | np.ndarray, **kwargs) -> None:
"""
Callback for counter updates of the mca channels (1-32). This callback is attached
to each mca channel PV on the MCS card. It collects data from all channels
and once all channels have been updated for a given acquisition, it pushes
the data to BEC through the AsyncMultiSignal 'mca'.
It is important that mux_output is set to the correct number of channels in on_connected,
because the callback here waits for updates on all channels before pushing data to BEC.
The _rlock is used to ensure thread safety as multiple callbacks may be executed
simultaneously from different threads.
If _omit_mca_callbacks is set, the callback will return immediately without processing the
data. This is used when erasing all channels to avoid interference with ongoing acquisition.
It has to manually cleared after the context manager 'suppress_mca_callbacks' is used.
Args:
value: The new value from the counter PV.
**kwargs: Additional keyword arguments from the subscription, including 'obj' (the EpicsSignalRO instance).
"""
with self._rlock:
if self._omit_mca_callbacks.is_set():
return # Suppress callbacks when erasing all channels
self._mca_counter_index += 1
signal: EpicsSignalRO | None = kwargs.get("obj", None)
if signal is None:
logger.error(f"Called without 'obj' in kwargs: {kwargs}")
return
# NOTE: This relies on the naming convention of the mca channels being 'mca1', 'mca2', ..., 'mca32'.
# for the MCSCard class with the 'counters' DynamicDeviceComponent.
# Ignore any updates from channels beyond NUM_MCA_CHANNELS
attr_name = signal.attr_name
index = int(attr_name[3:]) # Extract index from 'mcaX'
if index > self.NUM_MCA_CHANNELS:
return
# NOTE Depending on the scan parameters, we may either receive single values or numpy arrays.
# Therefore, we need to handle both cases here to ensure that data is always stored. We do
# this by converting single values to a list with one element, and numpy arrays to lists.
if isinstance(value, np.ndarray):
value = value.tolist() # Convert numpy array to list
else:
value = [value] # Received single value, convert to list
# Store the value with timestamp. If available in kwargs, use provided timestamp from CA,
# otherwise use current time when received.
self._current_data.update(
{attr_name: {"value": value, "timestamp": kwargs.get("timestamp") or time.time()}}
)
# Once we have received all channels, push data to BEC and reset for next accumulation
logger.debug(
f"Received update for {attr_name}, index {self._mca_counter_index}/{self.NUM_MCA_CHANNELS}"
)
if len(self._current_data) == self.NUM_MCA_CHANNELS:
logger.debug(
f"Current data index {self._current_data_index} complete, pushing to BEC."
)
self.mca.put(self._current_data, acquisition_group=self._acquisition_group)
self._current_data.clear()
self._mca_counter_index = 0
self._current_data_index += 1
# NOTE The logic for the device progress is not yet fully refined for all scan types.
# This has to be adjusted once fly scan and step scan logic is fully implemented.
# pylint: disable=unused-argument
def _progress_update(self, *args, old_value: any, value: any, **kwargs) -> None:
"""
Callback to update the progress signals base on values of current_channel in respect to expected total triggers.
Logic for these updates need to be extended once fly and step scan logic is fully implemented.
Args:
old_value: Previous value of the signal.
value: New value of the signal.
"""
scan_done = bool(value == self._num_total_triggers)
self.progress.put(value=value, max_value=self._num_total_triggers, done=scan_done)
if scan_done:
self._scan_done_event.set()
def on_stage(self) -> None:
"""
This method is called when the device is staged before a scan. Any bootstrapping required
for the scan should be handled here. We also need to handle MCS card specific logic to ensure
that the card is properly prepared for the scan.
The following procedure is implemented here:
- Ensure that any ongoing acquisition is stopped (should never happen if not interfered with manually)
- Erase all data on the MCS card to ensure a clean start (should never
- Set acquisition parameters based on scan parameters (frames_per_trigger, num_points, acquisition_group)
- Clear any events and buffers related to async data emission. This includes '_omit_mca_callbacks',
'_start_monitor_async_data_emission', '_scan_done_callbacks', and '_current_data'.
"""
start_time = time.time()
# NOTE: If for some reason, the card is still acquiring, we need to stop it first
# This should never happen as the card is properly stopped during unstage
# Can only happen if user manually interferes with the IOC through other means
if self.acquiring.get() == ACQUIRING.ACQUIRING:
logger.warning(
f"MCS Card {self.name} was still acquiring on staging. Stopping acquisition."
)
self.stop_all.put(1)
status = CompareStatus(self.acquiring, ACQUIRING.DONE)
status.wait(timeout=10)
# NOTE: If current_channel != 0, erase all data on the card. This
# needs to be done with the 'suppress_mca_callbacks' context manager as erase_all will result
# in data emission through mca callback subscriptions.
# The buffer needs to be cleared as this will otherwise lead to missing
# triggers during the scan. Again, this should not happen if unstage is properly called.
# But user interference or a restart of the device_server may lead to this situation.
if self.current_channel.get() != 0:
with suppress_mca_callbacks(self, restore_after_timeout=1.0):
logger.warning(
f"MCS Card {self.name} had still data in buffer Erased all data on staging and sleeping for 1 second."
)
# Erase all data on the MCS card
self.erase_all.put(1)
#####################################
### Setup Acquisition Parameters ###
#####################################
triggers = self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1)
num_points = self.scan_info.msg.num_points
self._num_total_triggers = triggers * num_points
self._acquisition_group = "monitored" if triggers == 1 else "burst_group"
self.preset_real.set(0).wait(timeout=self._pv_timeout)
self.num_use_all.set(triggers).wait(timeout=self._pv_timeout)
# Clear any previous data, just to be sure
with self._rlock:
self._current_data.clear()
self._mca_counter_index = 0
# NOTE Reset events for monitoring async_data_emission thread which is
# running during complete to wait for all data from the card
# to be emitted to BEC.
self._start_monitor_async_data_emission.clear()
# Clear any previous scan done callbacks
self._scan_done_callbacks.clear()
# Reset counter for data index of emitted data, NOTE for fly scans, this logic may have to be adjusted.
self._current_data_index = 0
# NOTE Make sure that the signal that omits mca callbacks is cleared
self._omit_mca_callbacks.clear()
logger.info(f"MCS Card {self.name} on_stage completed in {time.time() - start_time:.3f}s.")
def on_unstage(self) -> None:
"""
Called when the device is unstaged. This method should be omnipotent and resolve fast.
It stops any ongoing acquisition, erases all data on the MCS and clears the local buffer '_current_data'.
NOTE: It is important that the logic for on_complete is solid and properly waiting for mca data to be emitted
to BEC. Otherwise, unstage may interfere with ongoing data emission. Unstage is called after complete during scans.
It is crucial that the device itself calls '_omit_mca_callbacks' in its on_stage method to make sure
that data is emitted once the card is properly staged.
"""
self.stop_all.put(1)
with suppress_mca_callbacks(self):
with self._rlock:
self._current_data.clear()
self._current_data_index = 0
self.erase_all.put(1)
def _monitor_async_data_emission(self) -> None:
"""
Monitoring loop that runs in a separate thread to check if all async data has been emitted to BEC.
It is IDLE most of the time, but activate in the 'on_complete' method called by 'complete'.
The check is done by comparing the number of data updates '_current_data_index' received through
mca channel callbacks with the expected number of points in the scan. Once they match, all
callbacks in _scan_done_callbacks are called to indicate that data emission is done.
Callbacks need to also accept and handle exceptions to properly report failure.
NOTE! This logic currently works for any step scan, but has to be extended for fly scans.
"""
while not self._scan_done_thread_kill_event.is_set():
while self._start_monitor_async_data_emission.wait():
try:
logger.debug(f"Monitoring async data emission for {self.name}...")
if (
hasattr(self.scan_info.msg, "num_points")
and self.scan_info.msg.num_points is not None
):
if self._current_data_index == self.scan_info.msg.num_points:
for callback in self._scan_done_callbacks:
callback(exception=None)
time.sleep(0.02) # 20ms delay to avoid busy loop
except Exception as exc: # pylint: disable=broad-except
content = traceback.format_exc()
logger.error(
f"Exception in monitoring thread of complete for {self.name}:\n{content}"
"Running callbacks to avoid deadlock."
)
for callback in self._scan_done_callbacks:
callback(exception=exc)
def _status_callback(self, status: StatusBase, exception=None) -> None:
"""Callback for status completion."""
self._start_monitor_async_data_emission.clear() # Stop monitoring
# NOTE Important check as set_finished or set_exception should not be called
# if the status is already done (e.g. cancelled externally)
with self._rlock:
if status.done:
return # Already done and cancelled externally.
if exception is not None:
status.set_exception(exception)
else:
status.set_finished()
def _status_failed_callback(self, status: StatusBase) -> None:
"""Callback for status failure, the monitoring thread should be stopped."""
# NOTE Check for status.done and status.success is important to avoid
if status.done:
self._start_monitor_async_data_emission.clear() # Stop monitoring
def on_complete(self) -> CompareStatus:
"""
Method that is called at the end of scan core, but before unstage. This method is
used to report whether the device successfully completed its data acquisition for the scan.
The check has to be implemented asynchronously and resolve through a status (future) object
returned by this method.
NOTE: For the MCS card, we need to ensure that all data has been acquired
and emitted to BEC as updates after 'on_complete' resolved will be rejected by BEC.
Therefore, we need to ensure that all data has been emitted to BEC before
reporting completion of the device.
This method implements the following procedure:
- Starts the IDLE async data monitoring thread that checks if all expected data
has been emitted to BEC through the mca channel callbacks.
- Use a CompareStatus to monitor when the MCS card becomes DONE. Please note that this
only indicates that the card has finished acquisition, but not that all data has been
emitted to BEC.
- Return combined status object. A callback is registered to handle failure of the status
if it is stopped externally, e.g. through scan abort. This should ensure that the
monitoring thread is stopped properly.
"""
# Prepare and register status callback for the async monitoring loop
status_async_data = StatusBase(obj=self)
self._scan_done_callbacks.append(partial(self._status_callback, status_async_data))
# Set the event to start monitoring async data emission
logger.debug(f"Starting to monitor async data emission for {self.name}...")
self._start_monitor_async_data_emission.set()
# Add CompareStatus for Acquiring DONE
status = CompareStatus(self.acquiring, ACQUIRING.DONE)
# Combine both statuses
ret_status = status & status_async_data
# Handle external stop/cancel, and stop monitoring
ret_status.add_callback(self._status_failed_callback)
self.cancel_on_stop(ret_status)
return ret_status
def on_destroy(self):
"""
The on destroy hook is called when the device is destroyed, but also reloaded.
Here, we need to clean up all resources used up by the device, including running threads.
"""
self._scan_done_thread_kill_event.set()
self._start_monitor_async_data_emission.set()
if self._scan_done_thread.is_alive():
self._scan_done_thread.join(timeout=2.0)
if self._scan_done_thread.is_alive():
logger.warning(f"Thread for device {self.name} did not terminate properly.")
def on_stop(self) -> None:
"""Hook called when the device is stopped. In addition, any status that is registered through cancel_on_stop will be cancelled here."""
self.stop_all.put(1)
self.erase_all.put(1)
def mcs_recovery(self, timeout: int = 1) -> None:
"""
Recovery procedure for the mcs card. This procedure has been empirically found and can
be used to ensure that the MCS card is stopped and has no pending data to be emitted.
It involves stopping any ongoing acquisition and erasing all data on the card, with
a sleep in between to allow the IOC to process the commands.
Args:
timeout (int): Total timeout for the recovery procedure. Defaults to 1 second.
"""
sleep_time = timeout / 2 # 2 sleeps
logger.debug(
f"Running recovery procedure for MCS card {self.name} with {sleep_time}s sleep, calling stop_all and erase_all, and another {sleep_time}s sleep"
)
# First erase and start ongoing acquisition.
self.erase_start.put(1)
time.sleep(sleep_time)
# After a brief processing time, we stop any ongoing acquisition.
self.stop_all.put(1)
# Finally, we erase all data while suppressing mca callbacks to avoid interference.
# We restore the callback suppression after timeout to ensure proper operation afterwards.
with suppress_mca_callbacks(self, restore_after_timeout=sleep_time):
self.erase_all.put(1)

View File

@@ -1,319 +0,0 @@
import enum
import threading
from collections import defaultdict
import numpy as np
from bec_lib import bec_logger, messages
from bec_lib.endpoints import MessageEndpoints
from ophyd import Component as Cpt
from ophyd import Device, EpicsSignal, EpicsSignalRO
from ophyd_devices.interfaces.base_classes.psi_detector_base import (
CustomDetectorMixin,
PSIDetectorBase,
)
from ophyd_devices.utils import bec_utils
logger = bec_logger.logger
class MCSError(Exception):
"""Base class for exceptions in this module."""
class MCSTimeoutError(MCSError):
"""Raise when MCS card runs into a timeout"""
class TriggerSource(int, enum.Enum):
"""Trigger source for mcs card - see manual for more information"""
MODE0 = 0
MODE1 = 1
MODE2 = 2
MODE3 = 3
MODE4 = 4
MODE5 = 5
MODE6 = 6
class ChannelAdvance(int, enum.Enum):
"""Channel advance pixel mode for mcs card - see manual for more information"""
INTERNAL = 0
EXTERNAL = 1
class ReadoutMode(int, enum.Enum):
"""Readout mode for mcs card - see manual for more information"""
PASSIVE = 0
EVENT = 1
IO_INTR = 2
FREQ_0_1HZ = 3
FREQ_0_2HZ = 4
FREQ_0_5HZ = 5
FREQ_1HZ = 6
FREQ_2HZ = 7
FREQ_5HZ = 8
FREQ_10HZ = 9
FREQ_100HZ = 10
class MCSSetup(CustomDetectorMixin):
"""Setup mixin class for the MCS card"""
def __init__(self, *args, parent: Device = None, **kwargs) -> None:
super().__init__(*args, parent=parent, **kwargs)
self._lock = threading.RLock()
self._stream_ttl = 1800
self.acquisition_done = False
self.counter = 0
self.n_points = 0
self.mca_names = [
signal for signal in self.parent.component_names if signal.startswith("mca")
]
self.mca_data = defaultdict(lambda: [])
def on_init(self) -> None:
"""Init sequence for the detector"""
self.initialize_detector()
self.initialize_detector_backend()
def initialize_detector(self) -> None:
"""Initialize detector"""
# External trigger for pixel advance
self.parent.channel_advance.set(ChannelAdvance.EXTERNAL)
# Use internal clock for channel 1
self.parent.channel1_source.set(ChannelAdvance.INTERNAL)
self.parent.user_led.set(0)
# Set number of channels to 5
self.parent.mux_output.set(5)
# Trigger Mode used for cSAXS
self.parent.input_mode.set(TriggerSource.MODE3)
# specify polarity of trigger signals
self.parent.input_polarity.set(0)
self.parent.output_polarity.set(1)
# do not start counting on start
self.parent.count_on_start.set(0)
self.stop_detector()
def initialize_detector_backend(self) -> None:
"""Initialize detector backend"""
for mca in self.mca_names:
signal = getattr(self.parent, mca)
signal.subscribe(self._on_mca_data, run=False)
self.parent.current_channel.subscribe(self._progress_update, run=False)
def _progress_update(self, value, **kwargs) -> None:
"""Progress update on the scan"""
num_lines = self.parent.num_lines.get()
max_value = self.parent.scaninfo.num_points
# self.counter seems to be a deprecated variable from a former implementation of the mcs card
# pylint: disable=protected-access
self.parent._run_subs(
sub_type=self.parent.SUB_PROGRESS,
value=self.counter * int(self.parent.scaninfo.num_points / num_lines) + value,
max_value=max_value,
# TODO check if that is correct with
done=bool(max_value == value), # == self.counter),
)
def _on_mca_data(self, *args, obj=None, value=None, **kwargs) -> None:
"""Callback function for scan progress"""
with self._lock:
if not isinstance(value, (list, np.ndarray)):
return
self.mca_data[obj.attr_name] = value
if len(self.mca_names) != len(self.mca_data):
return
self.acquisition_done = True
self._send_data_to_bec()
self.mca_data = defaultdict(lambda: [])
def _send_data_to_bec(self) -> None:
"""Sends bundled data to BEC"""
if self.parent.scaninfo.scan_msg is None:
return
metadata = self.parent.scaninfo.scan_msg.metadata
metadata.update({"async_update": "append", "num_lines": self.parent.num_lines.get()})
msg = messages.DeviceMessage(
signals=dict(self.mca_data), metadata=self.parent.scaninfo.scan_msg.metadata
)
self.parent.connector.xadd(
topic=MessageEndpoints.device_async_readback(
scan_id=self.parent.scaninfo.scan_id, device=self.parent.name
),
msg={"data": msg},
expire=self._stream_ttl,
)
def on_stage(self) -> None:
"""Stage detector"""
self.prepare_detector()
self.prepare_detector_backend()
def prepare_detector(self) -> None:
"""Prepare detector for scan"""
self.set_acquisition_params()
self.parent.input_mode.set(TriggerSource.MODE3)
def set_acquisition_params(self) -> None:
"""Set acquisition parameters for scan"""
if self.parent.scaninfo.scan_type == "step":
self.n_points = int(self.parent.scaninfo.frames_per_trigger) * int(
self.parent.scaninfo.num_points
)
elif self.parent.scaninfo.scan_type == "fly":
self.n_points = int(self.parent.scaninfo.num_points) # / int(self.num_lines.get()))
else:
raise MCSError(f"Scantype {self.parent.scaninfo} not implemented for MCS card")
if self.n_points > 10000:
raise MCSError(
f"Requested number of points N={self.n_points} exceeds hardware limit of mcs card"
" 10000 (N-1)"
)
self.parent.num_use_all.set(self.n_points)
self.parent.preset_real.set(0)
def prepare_detector_backend(self) -> None:
"""Prepare detector backend for scan"""
self.parent.erase_all.set(1)
self.parent.read_mode.set(ReadoutMode.EVENT)
def arm_acquisition(self) -> None:
"""Arm detector for acquisition"""
self.counter = 0
self.parent.erase_start.set(1)
def on_unstage(self) -> None:
"""Unstage detector"""
pass
def on_complete(self) -> None:
"""Complete detector"""
self.finished(timeout=self.parent.TIMEOUT_FOR_SIGNALS)
def finished(self, timeout: int = 5) -> None:
"""Check if acquisition is finished, if not successful, rais MCSTimeoutError"""
signal_conditions = [
(lambda: self.acquisition_done, True),
(self.parent.acquiring.get, 0), # Considering making a enum.Int class for this state
]
if not self.wait_for_signals(
signal_conditions=signal_conditions,
timeout=timeout,
check_stopped=True,
all_signals=True,
):
total_frames = self.counter * int(
self.parent.scaninfo.num_points / self.parent.num_lines.get()
) + max(self.parent.current_channel.get(), 0)
raise MCSTimeoutError(
f"Reached timeout with mcs in state {self.parent.acquiring.get()} and"
f" {total_frames} frames arriving at the mcs card"
)
def on_stop(self) -> None:
"""Stop detector"""
self.stop_detector()
self.stop_detector_backend()
def stop_detector(self) -> None:
"""Stop detector"""
self.parent.stop_all.set(1)
def stop_detector_backend(self) -> None:
"""Stop acquisition of data"""
self.acquisition_done = True
class SIS38XX(Device):
"""SIS38XX card for access to EPICs PVs at cSAXS beamline"""
class MCScSAXS(PSIDetectorBase):
"""MCS card for cSAXS for implementation at cSAXS beamline"""
USER_ACCESS = []
SUB_PROGRESS = "progress"
SUB_VALUE = "value"
_default_sub = SUB_VALUE
# specify Setup class
custom_prepare_cls = MCSSetup
# specify minimum readout time for detector
MIN_READOUT = 0
TIMEOUT_FOR_SIGNALS = 5
# PV access to SISS38XX card
# Acquisition
erase_all = Cpt(EpicsSignal, "EraseAll")
erase_start = Cpt(EpicsSignal, "EraseStart") # ,trigger_value=1
start_all = Cpt(EpicsSignal, "StartAll")
stop_all = Cpt(EpicsSignal, "StopAll")
acquiring = Cpt(EpicsSignal, "Acquiring")
preset_real = Cpt(EpicsSignal, "PresetReal")
elapsed_real = Cpt(EpicsSignal, "ElapsedReal")
read_mode = Cpt(EpicsSignal, "ReadAll.SCAN")
read_all = Cpt(EpicsSignal, "DoReadAll.VAL") # ,trigger_value=1
num_use_all = Cpt(EpicsSignal, "NuseAll")
current_channel = Cpt(EpicsSignal, "CurrentChannel")
dwell = Cpt(EpicsSignal, "Dwell")
channel_advance = Cpt(EpicsSignal, "ChannelAdvance")
count_on_start = Cpt(EpicsSignal, "CountOnStart")
software_channel_advance = Cpt(EpicsSignal, "SoftwareChannelAdvance")
channel1_source = Cpt(EpicsSignal, "Channel1Source")
prescale = Cpt(EpicsSignal, "Prescale")
enable_client_wait = Cpt(EpicsSignal, "EnableClientWait")
client_wait = Cpt(EpicsSignal, "ClientWait")
acquire_mode = Cpt(EpicsSignal, "AcquireMode")
mux_output = Cpt(EpicsSignal, "MUXOutput")
user_led = Cpt(EpicsSignal, "UserLED")
input_mode = Cpt(EpicsSignal, "InputMode")
input_polarity = Cpt(EpicsSignal, "InputPolarity")
output_mode = Cpt(EpicsSignal, "OutputMode")
output_polarity = Cpt(EpicsSignal, "OutputPolarity")
model = Cpt(EpicsSignalRO, "Model", string=True)
firmware = Cpt(EpicsSignalRO, "Firmware")
max_channels = Cpt(EpicsSignalRO, "MaxChannels")
# PV access to MCA signals
mca1 = Cpt(EpicsSignalRO, "mca1.VAL", auto_monitor=True)
mca3 = Cpt(EpicsSignalRO, "mca3.VAL", auto_monitor=True)
mca4 = Cpt(EpicsSignalRO, "mca4.VAL", auto_monitor=True)
current_channel = Cpt(EpicsSignalRO, "CurrentChannel", auto_monitor=True)
# Custom signal readout from device config
num_lines = Cpt(
bec_utils.ConfigSignal, name="num_lines", kind="config", config_storage_name="mcs_config"
)
def __init__(
self,
prefix="",
*,
name,
kind=None,
parent=None,
device_manager=None,
mcs_config=None,
**kwargs,
):
self.mcs_config = {f"{name}_num_lines": 1}
if mcs_config is not None:
# pylint: disable=expression-not-assigned
[self.mcs_config.update({f"{name}_{key}": value}) for key, value in mcs_config.items()]
super().__init__(
prefix=prefix,
name=name,
kind=kind,
parent=parent,
device_manager=device_manager,
**kwargs,
)
# Automatically connect to test environmenr if directly invoked
if __name__ == "__main__":
mcs = MCScSAXS(name="mcs", prefix="X12SA-MCS:", sim_mode=True)

View File

@@ -1,400 +0,0 @@
import enum
import json
import os
import threading
import time
import numpy as np
import requests
from bec_lib import bec_logger
from ophyd import ADComponent as ADCpt
from ophyd import Device, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV, Staged
from ophyd_devices.interfaces.base_classes.psi_detector_base import (
CustomDetectorMixin,
PSIDetectorBase,
)
logger = bec_logger.logger
class PilatusError(Exception):
"""Base class for exceptions in this module."""
class PilatusTimeoutError(PilatusError):
"""Raised when the Pilatus does not respond in time during unstage."""
class TriggerSource(enum.IntEnum):
"""Trigger source options for the detector"""
INTERNAL = 0
EXT_ENABLE = 1
EXT_TRIGGER = 2
MULTI_TRIGGER = 3
ALGINMENT = 4
class SLSDetectorCam(Device):
"""SLS Detector Camera - Pilatus
Base class to map EPICS PVs to ophyd signals.
"""
num_images = ADCpt(EpicsSignalWithRBV, "NumImages")
num_frames = ADCpt(EpicsSignalWithRBV, "NumExposures")
delay_time = ADCpt(EpicsSignalWithRBV, "NumExposures")
trigger_mode = ADCpt(EpicsSignalWithRBV, "TriggerMode")
acquire = ADCpt(EpicsSignal, "Acquire")
armed = ADCpt(EpicsSignalRO, "Armed")
read_file_timeout = ADCpt(EpicsSignal, "ImageFileTmot")
detector_state = ADCpt(EpicsSignalRO, "StatusMessage_RBV")
status_message_camserver = ADCpt(EpicsSignalRO, "StringFromServer_RBV", string=True)
acquire_time = ADCpt(EpicsSignal, "AcquireTime")
acquire_period = ADCpt(EpicsSignal, "AcquirePeriod")
threshold_energy = ADCpt(EpicsSignalWithRBV, "ThresholdEnergy")
file_path = ADCpt(EpicsSignalWithRBV, "FilePath")
file_name = ADCpt(EpicsSignalWithRBV, "FileName")
file_number = ADCpt(EpicsSignalWithRBV, "FileNumber")
auto_increment = ADCpt(EpicsSignalWithRBV, "AutoIncrement")
file_template = ADCpt(EpicsSignalWithRBV, "FileTemplate")
file_format = ADCpt(EpicsSignalWithRBV, "FileNumber")
gap_fill = ADCpt(EpicsSignalWithRBV, "GapFill")
class PilatusSetup(CustomDetectorMixin):
"""Pilatus setup class for cSAXS
Parent class: CustomDetectorMixin
"""
def __init__(self, *args, parent: Device = None, **kwargs) -> None:
super().__init__(*args, parent=parent, **kwargs)
self._lock = threading.RLock()
def on_init(self) -> None:
"""Initialize the detector"""
self.initialize_default_parameter()
self.initialize_detector()
def initialize_default_parameter(self) -> None:
"""Set default parameters for Eiger9M detector"""
self.update_readout_time()
def update_readout_time(self) -> None:
"""Set readout time for Eiger9M detector"""
readout_time = (
self.parent.scaninfo.readout_time
if hasattr(self.parent.scaninfo, "readout_time")
else self.parent.MIN_READOUT
)
self.parent.readout_time = max(readout_time, self.parent.MIN_READOUT)
def initialize_detector(self) -> None:
"""Initialize detector"""
# Stops the detector
self.stop_detector()
# Sets the trigger source to GATING
self.parent.cam.trigger_mode.put(TriggerSource.EXT_ENABLE)
def on_stage(self) -> None:
"""Stage the detector for scan"""
self.prepare_detector()
self.prepare_data_backend()
self.publish_file_location(
done=False, successful=False, metadata={"input_path": self.parent.filepath_raw}
)
def prepare_detector(self) -> None:
"""
Prepare detector for scan.
Includes checking the detector threshold,
setting the acquisition parameters and setting the trigger source
"""
self.set_detector_threshold()
self.set_acquisition_params()
self.parent.cam.trigger_mode.put(TriggerSource.EXT_ENABLE)
def prepare_data_backend(self) -> None:
"""
Prepare the detector backend of pilatus for a scan
A zmq service is running on xbl-daq-34 that is waiting
for a zmq message to start the writer for the pilatus_2 x12sa-pd-2
"""
self.stop_detector_backend()
self.parent.filepath.set(
self.parent.filewriter.compile_full_filename("pilatus_2.h5")
).wait()
self.parent.cam.file_path.put("/dev/shm/zmq/")
self.parent.cam.file_name.put(
f"{self.parent.scaninfo.username}_2_{self.parent.scaninfo.scan_number:05d}"
)
self.parent.cam.auto_increment.put(1) # auto increment
self.parent.cam.file_number.put(0) # first iter
self.parent.cam.file_format.put(0) # 0: TIFF
self.parent.cam.file_template.put("%s%s_%5.5d.cbf")
# TODO better to remove hard coded path with link to home directory/pilatus_2
basepath = f"/sls/X12SA/data/{self.parent.scaninfo.username}/Data10/pilatus_2/"
self.parent.filepath_raw = os.path.join(
basepath,
self.parent.filewriter.get_scan_directory(self.parent.scaninfo.scan_number, 1000, 5),
)
# Make directory if needed
self.create_directory(self.parent.filepath_raw)
headers = {"Content-Type": "application/json", "Accept": "application/json"}
# start the stream on x12sa-pd-2
url = "http://x12sa-pd-2:8080/stream/pilatus_2"
data_msg = {
"source": [
{
"searchPath": "/",
"searchPattern": "glob:*.cbf",
"destinationPath": self.parent.filepath_raw,
}
]
}
res = self.send_requests_put(url=url, data=data_msg, headers=headers)
logger.info(f"{res.status_code} - {res.text} - {res.content}")
if not res.ok:
res.raise_for_status()
# start the data receiver on xbl-daq-34
url = "http://xbl-daq-34:8091/pilatus_2/run"
data_msg = [
"zmqWriter",
self.parent.scaninfo.username,
{
"addr": "tcp://x12sa-pd-2:8888",
"dst": ["file"],
"numFrm": int(
self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger
),
"timeout": 2000,
"ifType": "PULL",
"user": self.parent.scaninfo.username,
},
]
res = self.send_requests_put(url=url, data=data_msg, headers=headers)
logger.info(f"{res.status_code} - {res.text} - {res.content}")
if not res.ok:
res.raise_for_status()
# Wait for server to become available again
time.sleep(0.1)
logger.info(f"{res.status_code} -{res.text} - {res.content}")
# Send requests.put to xbl-daq-34 to wait for data
url = "http://xbl-daq-34:8091/pilatus_2/wait"
data_msg = [
"zmqWriter",
self.parent.scaninfo.username,
{
"frmCnt": int(
self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger
),
"timeout": 2000,
},
]
try:
res = self.send_requests_put(url=url, data=data_msg, headers=headers)
logger.info(f"{res}")
if not res.ok:
res.raise_for_status()
except Exception as exc:
logger.info(f"Pilatus2 wait threw Exception: {exc}")
def set_detector_threshold(self) -> None:
"""
Set correct detector threshold to 1/2 of current X-ray energy, allow 5% tolerance
Threshold might be in ev or keV
"""
# get current beam energy from device manageer
mokev = self.parent.device_manager.devices.mokev.obj.read()[
self.parent.device_manager.devices.mokev.name
]["value"]
factor = 1
# Check if energies are eV or keV, assume keV as the default
unit = getattr(self.parent.cam.threshold_energy, "units", None)
if unit is not None and unit == "eV":
factor = 1000
# set energy on detector
setpoint = int(mokev * factor)
# set threshold on detector
threshold = self.parent.cam.threshold_energy.read()[self.parent.cam.threshold_energy.name][
"value"
]
if not np.isclose(setpoint / 2, threshold, rtol=0.05):
self.parent.cam.threshold_energy.set(setpoint / 2)
def set_acquisition_params(self) -> None:
"""Set acquisition parameters for the detector"""
# Set number of images and frames (frames is for internal burst of detector)
self.parent.cam.num_images.put(
int(self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger)
)
self.parent.cam.num_frames.put(1)
# Update the readout time of the detector
self.update_readout_time()
def create_directory(self, filepath: str) -> None:
"""Create directory if it does not exist"""
os.makedirs(filepath, exist_ok=True)
def close_file_writer(self) -> None:
"""
Close the file writer for pilatus_2
Delete the data from x12sa-pd-2
"""
url = "http://x12sa-pd-2:8080/stream/pilatus_2"
try:
res = self.send_requests_delete(url=url)
if not res.ok:
res.raise_for_status()
except Exception as exc:
logger.info(f"Pilatus2 close threw Exception: {exc}")
def stop_file_writer(self) -> None:
"""
Stop the file writer for pilatus_2
Runs on xbl-daq-34
"""
url = "http://xbl-daq-34:8091/pilatus_2/stop"
res = self.send_requests_put(url=url)
if not res.ok:
res.raise_for_status()
def send_requests_put(self, url: str, data: list = None, headers: dict = None) -> object:
"""
Send a put request to the given url
Args:
url (str): url to send the request to
data (dict): data to be sent with the request (optional)
headers (dict): headers to be sent with the request (optional)
Returns:
status code of the request
"""
return requests.put(url=url, data=json.dumps(data), headers=headers, timeout=5)
def send_requests_delete(self, url: str, headers: dict = None) -> object:
"""
Send a delete request to the given url
Args:
url (str): url to send the request to
headers (dict): headers to be sent with the request (optional)
Returns:
status code of the request
"""
return requests.delete(url=url, headers=headers, timeout=5)
def on_pre_scan(self) -> None:
"""Prepare detector for scan"""
self.arm_acquisition()
def arm_acquisition(self) -> None:
"""Arms the detector for the acquisition"""
self.parent.cam.acquire.put(1)
# TODO is this sleep needed? to be tested with detector and for how long
time.sleep(0.5)
def on_unstage(self) -> None:
"""Unstage the detector"""
pass
def on_complete(self) -> None:
"""Complete the scan"""
self.finished(timeout=self.parent.TIMEOUT_FOR_SIGNALS)
self.publish_file_location(
done=True, successful=True, metadata={"input_path": self.parent.filepath_raw}
)
def finished(self, timeout: int = 5) -> None:
"""Check if acquisition is finished."""
# pylint: disable=protected-access
# TODO: at the moment this relies on device.mcs.obj._staged attribute
signal_conditions = [
(lambda: self.parent.device_manager.devices.mcs.obj._staged, Staged.no)
]
if not self.wait_for_signals(
signal_conditions=signal_conditions,
timeout=timeout,
check_stopped=True,
all_signals=True,
):
raise PilatusTimeoutError(
f"Reached timeout with detector state {signal_conditions[0][0]}, std_daq state"
f" {signal_conditions[1][0]} and received frames of {signal_conditions[2][0]} for"
" the file writer"
)
self.stop_detector()
self.stop_detector_backend()
def on_stop(self) -> None:
"""Stop detector"""
self.stop_detector()
self.stop_detector_backend()
def stop_detector(self) -> None:
"""Stop detector"""
self.parent.cam.acquire.put(0)
def stop_detector_backend(self) -> None:
"""Stop the file writer zmq service for pilatus_2"""
self.close_file_writer()
time.sleep(0.1)
self.stop_file_writer()
time.sleep(0.1)
class PilatuscSAXS(PSIDetectorBase):
"""Pilatus_2 300k detector for CSAXS
Parent class: PSIDetectorBase
class attributes:
custom_prepare_cls (Eiger9MSetup) : Custom detector setup class for cSAXS,
inherits from CustomDetectorMixin
cam (SLSDetectorCam) : Detector camera
MIN_READOUT (float) : Minimum readout time for the detector
"""
# Specify which functions are revealed to the user in BEC client
USER_ACCESS = []
# specify Setup class
custom_prepare_cls = PilatusSetup
# specify minimum readout time for detector
MIN_READOUT = 3e-3
TIMEOUT_FOR_SIGNALS = 5
# specify class attributes
cam = ADCpt(SLSDetectorCam, "cam1:")
if __name__ == "__main__":
pilatus_2 = PilatuscSAXS(name="pilatus_2", prefix="X12SA-ES-PILATUS300K:", sim_mode=True)

View File

@@ -0,0 +1,127 @@
import time
from ophyd import Component as Cpt
from ophyd import Device, EpicsSignalRO, Signal
class SumSignal(Signal):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._metadata.update(write_access=False)
def wait_for_connection(self, timeout=0):
super().wait_for_connection(timeout)
self._metadata.update(connected=True)
def get(self, **kwargs):
self._metadata["timestamp"] = time.time()
val1 = self.parent.current1.get()
val2 = self.parent.current2.get()
val3 = self.parent.current3.get()
val4 = self.parent.current4.get()
return val1 + val2 + val3 + val4
def describe(self):
source = [
self.parent.current1.describe()[self.parent.current1.name]["source"],
self.parent.current2.describe()[self.parent.current2.name]["source"],
self.parent.current3.describe()[self.parent.current3.name]["source"],
self.parent.current4.describe()[self.parent.current4.name]["source"],
]
source = " / ".join(source)
desc = {
"shape": [],
"dtype": "number",
"source": f"PV: {source}",
"units": "",
"precision": (
self.parent.current1.precision if hasattr(self.parent.current1, "precision") else 0
),
}
return desc
class DiffXYSignal(Signal):
def __init__(self, sum1, sum2, *args, **kwargs):
self.sum1 = sum1
self.sum2 = sum2
super().__init__(*args, **kwargs)
self._metadata.update(write_access=False)
def wait_for_connection(self, timeout=0):
super().wait_for_connection(timeout)
self._metadata.update(connected=True)
def get(self, **kwargs):
self._metadata["timestamp"] = time.time()
summed_1 = 0
summed_2 = 0
for signal in self.sum1:
summed_1 += getattr(self.parent, signal).get()
for signal in self.sum2:
summed_2 += getattr(self.parent, signal).get()
_sum = summed_1 + summed_2
if _sum == 0:
return 0.0
return (summed_1 - summed_2) / _sum
def describe(self):
source = [
getattr(self.parent, signal).describe()[getattr(self.parent, signal).name]["source"]
for signal in self.sum1 + self.sum2
]
source = " / ".join(source)
desc = {
"shape": [],
"dtype": "number",
"source": f"PV: {source}",
"units": "",
"precision": (
self.parent.current1.precision if hasattr(self.parent.current1, "precision") else 0
),
}
return desc
class BPMDevice(Device):
current1 = Cpt(
EpicsSignalRO, ":Current1:MeanValue_RBV", kind="normal", doc="Current 1", auto_monitor=True
)
current2 = Cpt(
EpicsSignalRO, ":Current2:MeanValue_RBV", kind="normal", doc="Current 2", auto_monitor=True
)
current3 = Cpt(
EpicsSignalRO, ":Current3:MeanValue_RBV", kind="normal", doc="Current 3", auto_monitor=True
)
current4 = Cpt(
EpicsSignalRO, ":Current4:MeanValue_RBV", kind="normal", doc="Current 4", auto_monitor=True
)
sum = Cpt(SumSignal, kind="hinted", doc="Sum of all currents")
x = Cpt(
DiffXYSignal,
sum1=["current1", "current2"],
sum2=["current3", "current4"],
doc="X difference signal",
)
y = Cpt(
DiffXYSignal,
sum1=["current1", "current3"],
sum2=["current2", "current4"],
doc="Y difference signal",
)
diag = Cpt(
DiffXYSignal,
sum1=["current1", "current4"],
sum2=["current2", "current3"],
doc="Diagonal difference signal",
)
def __init__(self, prefix="", *args, **kwargs):
super().__init__(*args, prefix=prefix, **kwargs)
if __name__ == "__main__":
dev = BPMDevice(name="bpm", prefix="X12SA-FE-XBPM1")
dev.wait_for_connection()
print(dev.read())

View File

@@ -0,0 +1 @@
from .ids_camera import IDSCamera

View File

@@ -0,0 +1,310 @@
"""
This module provides a Camera class for handling IDS cameras using the pyueye library,
that links to the vendors C++ SDK. Details about the camera's C++ SDK API can be found
in the IDS Software Suite 4.96.1 documentation:
(https://www.1stvision.com/cameras/IDS/IDS-manuals/uEye_Manual/sdk_einleitung_schnellstart.html)
Here, we follow a procedure to set up the camera, configure its basic parameters and
allow automated capturing of images. The IDSCameraObject class is the low-level interface,
and requires the pyueye library and appropriate DLL files on the system. The Camera class
provides a high level interface which only creates the IDSCameraObject instance when the
on_connect method is called. This allows for lazy initialization of the camera, and
CI/CD pipelines can run without the pyueye library or the related DLLs installed on the system.
"""
from __future__ import annotations
import atexit
import time
from typing import Literal
import numpy as np
from bec_lib.logger import bec_logger
from csaxs_bec.devices.ids_cameras.base_integration.utils import check_error
logger = bec_logger.logger
try:
from pyueye import ueye
except ImportError as exc:
logger.warning(f"The pyueye library is not properly installed : {exc}")
ueye = None # type: ignore[assignment]
class IDSCameraObject:
"""Low-level base class for IDS Camera object.
Args:
device_id (int): The ID of the camera device. # e.g. 201; check idscamera tool
m_n_colormode (int): Color mode for the camera. # 1 for cSAXS color cameras
bits_per_pixel (int): Number of bits per pixel for the camera. # 24 for color cameras, 8 for monochrome cameras
"""
def __init__(self, device_id: int, m_n_colormode, bits_per_pixel):
if ueye is None:
raise ImportError(
"The pyueye library is not installed or library files are missing. Please check your Python environment or library paths."
)
self.ueye = ueye
self._device_id = device_id
self.h_cam = ueye.HIDS(device_id)
self.s_info = ueye.SENSORINFO()
self.c_info = ueye.CAMINFO()
self.rect_roi = ueye.IS_RECT()
self.pc_image_mem = ueye.c_mem_p()
self.mem_id = ueye.int()
self.pitch = ueye.INT()
self.m_n_colormode = ueye.INT(m_n_colormode)
self.n_bits_per_pixel = ueye.INT(bits_per_pixel)
self.bytes_per_pixel = int(self.n_bits_per_pixel / 8)
# Sequence to initialize the camera
check_error(ueye.is_InitCamera(self.h_cam, None), "IDSCameraObject")
check_error(ueye.is_GetSensorInfo(self.h_cam, self.s_info), "IDSCameraObject")
check_error(ueye.is_GetCameraInfo(self.h_cam, self.c_info), "IDSCameraObject")
check_error(ueye.is_ResetToDefault(self.h_cam), "IDSCameraObject")
check_error(ueye.is_SetDisplayMode(self.h_cam, ueye.IS_SET_DM_DIB), "IDSCameraObject")
if (
int.from_bytes(self.s_info.nColorMode.value, byteorder="big")
== self.ueye.IS_COLORMODE_BAYER
):
logger.info("Bayer color mode detected.")
# setup the color depth to the current windows setting
self.ueye.is_GetColorDepth(
self.h_cam, self.n_bits_per_pixel, self.m_n_colormode
) # TODO This raises an error - maybe check the m_n_colormode value
self.bytes_per_pixel = int(self.n_bits_per_pixel / 8)
elif (
int.from_bytes(self.s_info.nColorMode.value, byteorder="big")
== self.ueye.IS_COLORMODE_CBYCRY
):
# for color camera models use RGB32 mode
self.m_n_colormode = self.ueye.IS_CM_BGRA8_PACKED
self.n_bits_per_pixel = self.ueye.INT(32)
self.bytes_per_pixel = int(self.n_bits_per_pixel / 8)
elif (
int.from_bytes(self.s_info.nColorMode.value, byteorder="big")
== self.ueye.IS_COLORMODE_MONOCHROME
):
# for color camera models use RGB32 mode
self.m_n_colormode = self.ueye.IS_CM_MONO8
self.n_bits_per_pixel = self.ueye.INT(8)
self.bytes_per_pixel = int(self.n_bits_per_pixel / 8)
else:
# for monochrome camera models use Y8 mode
self.m_n_colormode = self.ueye.IS_CM_MONO8
self.n_bits_per_pixel = self.ueye.INT(8)
self.bytes_per_pixel = int(self.n_bits_per_pixel / 8)
logger.info("Monochrome camera mode detected.")
# Can be used to set the size and position of an "area of interest"(AOI) within an image
check_error(
self.ueye.is_AOI(
self.h_cam,
self.ueye.IS_AOI_IMAGE_GET_AOI,
self.rect_roi,
self.ueye.sizeof(self.rect_roi),
),
"IDSCameraObject",
)
self.width = self.rect_roi.s32Width
self.height = self.rect_roi.s32Height
check_error(
self.ueye.is_AllocImageMem(
self.h_cam,
self.width,
self.height,
self.n_bits_per_pixel,
self.pc_image_mem,
self.mem_id,
),
"IDSCameraObject",
)
check_error(
self.ueye.is_SetImageMem(self.h_cam, self.pc_image_mem, self.mem_id), "IDSCameraObject"
)
check_error(self.ueye.is_SetColorMode(self.h_cam, self.m_n_colormode), "IDSCameraObject")
check_error(
self.ueye.is_CaptureVideo(self.h_cam, self.ueye.IS_DONT_WAIT), "IDSCameraObject"
)
check_error(
self.ueye.is_InquireImageMem(
self.h_cam,
self.pc_image_mem,
self.mem_id,
self.width,
self.height,
self.n_bits_per_pixel,
self.pitch,
),
"IDSCameraObject",
)
def __repr__(self):
return f"IDSCameraObject\n\ndevice_id={self._device_id},\ns_info={self.s_info},\nc_info={self.c_info},\nrect_roi={self.rect_roi},\npc_image_mem={self.pc_image_mem},\nmem_id={self.mem_id},\npitch={self.pitch},\nm_n_colormode={self.m_n_colormode},\nn_bits_per_pixel={self.n_bits_per_pixel},\nbytes_per_pixel={self.bytes_per_pixel}"
class Camera:
"""High level camera base class for IDS cameras.
Args:
camera_id (int): The ID of the camera device.
m_n_colormode (Literal[0, 1, 2, 3]): Color mode for the camera.
bits_per_pixel (Literal[8, 24]): Number of bits per pixel for the camera.
live_mode (bool): Whether to enable live mode for the camera.
"""
def __init__(
self,
camera_id: int,
m_n_colormode: Literal[0, 1, 2, 3] = 1,
bits_per_pixel: int = 24,
connect: bool = True,
force_monochrome: bool = False,
):
self.ueye = ueye
self.camera_id = camera_id
self._inputs = {"m_n_colormode": m_n_colormode, "bits_per_pixel": bits_per_pixel}
self.force_monochrome = force_monochrome
self._connected = False
self.cam = None
atexit.register(self.on_disconnect)
self._enable_warning_rate_limit: bool = False
self._last_rate_limited_log: float = 0
self._warning_log_rate_limit_s: float = 10
if connect:
self.on_connect()
def set_roi(self, x: int, y: int, width: int, height: int):
"""Set the region of interest (ROI) for the camera."""
rect_roi = ueye.IS_RECT()
rect_roi.s32X = x
rect_roi.s32Y = y
rect_roi.s32Width = width
rect_roi.s32Height = height
ret = self.ueye.is_AOI(
self.cam.h_cam, self.ueye.IS_AOI_IMAGE_SET_AOI, rect_roi, self.ueye.sizeof(rect_roi)
)
check_error(ret, "IDSCameraObject")
logger.info(f"ROI set to: {rect_roi}")
def on_connect(self):
"""Connect to the camera and initialize it."""
if self._connected:
logger.warning("Camera is already connected.")
return
self.cam = IDSCameraObject(self.camera_id, **self._inputs)
self._connected = True
def on_disconnect(self, delay_after: float = 0.3):
"""Disconnect from the camera and optionally wait a short time for driver cleanup."""
try:
if self.cam and self.cam.h_cam:
check_error(self.ueye.is_ExitCamera(self.cam.h_cam), "IDSCameraObject")
self._connected = False
self.cam = None
if delay_after > 0:
time.sleep(delay_after)
logger.debug(f"Waited {delay_after:.2f}s after camera disconnect for cleanup.")
except Exception as e:
logger.info(f"Error during camera disconnection: {e}")
@property
def exposure_time(self) -> float:
"""Get the exposure time of the camera."""
exposure = ueye.c_double()
ret = self.ueye.is_Exposure(self.cam.h_cam, ueye.IS_EXPOSURE_CMD_GET_EXPOSURE, exposure, 8)
check_error(ret, "IDSCameraObject")
return exposure.value
@exposure_time.setter
def exposure_time(self, value: float):
"""Set the exposure time of the camera."""
exposure = ueye.c_double(value)
check_error(
self.ueye.is_Exposure(self.cam.h_cam, ueye.IS_EXPOSURE_CMD_SET_EXPOSURE, exposure, 8),
"IDSCameraObject",
)
def set_auto_gain(self, enable: bool):
"""Enable or disable auto gain."""
enable = ueye.c_int(1) if enable else ueye.c_int(0)
value_to_return = ueye.c_double()
check_error(
self.ueye.is_SetAutoParameter(
self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_GAIN, enable, value_to_return
),
"IDSCameraObject",
)
def set_auto_shutter(self, enable: bool):
"""Enable or disable auto exposure."""
enable = ueye.c_int(1) if enable else ueye.c_int(0)
value_to_return = ueye.c_double()
check_error(
self.ueye.is_SetAutoParameter(
self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_SHUTTER, enable, value_to_return
),
"IDSCameraObject",
)
def get_image_data(self) -> np.ndarray | None:
"""Get the image data from the camera."""
if not self._connected:
self._rate_limited_warning_log("Camera is not connected.")
return None
array = self.ueye.get_data(
self.cam.pc_image_mem,
self.cam.width,
self.cam.height,
self.cam.n_bits_per_pixel,
self.cam.pitch,
copy=False,
)
if array is None:
logger.error("Failed to get image data from the camera.")
return None
img = np.reshape(
array, (self.cam.height.value, self.cam.width.value, self.cam.bytes_per_pixel)
)
# If RGB image (H, W, 3), reshuffle channels from BGR → RGB
if img.ndim == 3 and img.shape[2] == 3:
img = img[:, :, ::-1]
if self.force_monochrome:
gray = np.dot(img[..., :3], [0.2989, 0.5870, 0.1140]).astype(np.uint8)
# expand to 3D shape (H, W, 1) for consistency with real mono cams
img = np.expand_dims(gray, axis=-1)
img = np.ascontiguousarray(img)
return img
def set_camera_rate_limiting(self, enabled: bool, rate_limit_s: float | None = None):
if rate_limit_s is not None:
if rate_limit_s <= 0:
raise ValueError(f"Invalid rate limit: {rate_limit_s}, must be positive nonzero.")
self._warning_log_rate_limit_s = rate_limit_s
self._enable_warning_rate_limit = enabled
def _rate_limited_warning_log(self, msg: "str"):
if (
self._enable_warning_rate_limit
and time.monotonic() < self._last_rate_limited_log + self._warning_log_rate_limit_s
):
return
self._last_rate_limited_log = time.monotonic()
logger.warning(msg)
if __name__ == "__main__":
# Example usage
camera = Camera(camera_id=201)
camera.on_connect()

View File

@@ -0,0 +1,282 @@
"""Utility functions and classes for IDS cameras using the pyueye library."""
from bec_lib.logger import bec_logger
logger = bec_logger.logger
try:
from pyueye import ueye
except ImportError as exc:
logger.warning(f"The pyueye library is not properly installed : {exc}")
ueye = None
if ueye is not None:
error_codes = {
ueye.IS_NO_SUCCESS: "No success",
ueye.IS_SUCCESS: "Success",
ueye.IS_INVALID_CAMERA_HANDLE: "Invalid camera handle",
ueye.IS_INVALID_HANDLE: "Invalid handle",
ueye.IS_IO_REQUEST_FAILED: "IO request failed",
ueye.IS_CANT_OPEN_DEVICE: "Cannot open device",
ueye.IS_CANT_CLOSE_DEVICE: "Cannot close device",
ueye.IS_CANT_SETUP_MEMORY: "Cannot setup memory",
ueye.IS_NO_HWND_FOR_ERROR_REPORT: "No HWND for error report",
ueye.IS_ERROR_MESSAGE_NOT_CREATED: "Error message not created",
ueye.IS_ERROR_STRING_NOT_FOUND: "Error string not found",
ueye.IS_HOOK_NOT_CREATED: "Hook not created",
ueye.IS_TIMER_NOT_CREATED: "Timer not created",
ueye.IS_CANT_OPEN_REGISTRY: "Cannot open registry",
ueye.IS_CANT_READ_REGISTRY: "Cannot read registry",
ueye.IS_CANT_VALIDATE_BOARD: "Cannot validate board",
ueye.IS_CANT_GIVE_BOARD_ACCESS: "Cannot give board access",
ueye.IS_NO_IMAGE_MEM_ALLOCATED: "No image memory allocated",
ueye.IS_CANT_CLEANUP_MEMORY: "Cannot clean up memory",
ueye.IS_CANT_COMMUNICATE_WITH_DRIVER: "Cannot communicate with driver",
ueye.IS_FUNCTION_NOT_SUPPORTED_YET: "Function not supported yet",
ueye.IS_OPERATING_SYSTEM_NOT_SUPPORTED: "Operating system not supported",
ueye.IS_INVALID_VIDEO_IN: "Invalid video input",
ueye.IS_INVALID_IMG_SIZE: "Invalid image size",
ueye.IS_INVALID_ADDRESS: "Invalid address",
ueye.IS_INVALID_VIDEO_MODE: "Invalid video mode",
ueye.IS_INVALID_AGC_MODE: "Invalid AGC mode",
ueye.IS_INVALID_GAMMA_MODE: "Invalid gamma mode",
ueye.IS_INVALID_SYNC_LEVEL: "Invalid sync level",
ueye.IS_INVALID_CBARS_MODE: "Invalid color bars mode",
ueye.IS_INVALID_COLOR_MODE: "Invalid color mode",
ueye.IS_INVALID_SCALE_FACTOR: "Invalid scale factor",
ueye.IS_INVALID_IMAGE_SIZE: "Invalid image size",
ueye.IS_INVALID_IMAGE_POS: "Invalid image position",
ueye.IS_INVALID_CAPTURE_MODE: "Invalid capture mode",
ueye.IS_INVALID_RISC_PROGRAM: "Invalid RISC program",
ueye.IS_INVALID_BRIGHTNESS: "Invalid brightness",
ueye.IS_INVALID_CONTRAST: "Invalid contrast",
ueye.IS_INVALID_SATURATION_U: "Invalid saturation U",
ueye.IS_INVALID_SATURATION_V: "Invalid saturation V",
ueye.IS_INVALID_HUE: "Invalid hue",
ueye.IS_INVALID_HOR_FILTER_STEP: "Invalid horizontal filter step",
ueye.IS_INVALID_VERT_FILTER_STEP: "Invalid vertical filter step",
ueye.IS_INVALID_EEPROM_READ_ADDRESS: "Invalid EEPROM read address",
ueye.IS_INVALID_EEPROM_WRITE_ADDRESS: "Invalid EEPROM write address",
ueye.IS_INVALID_EEPROM_READ_LENGTH: "Invalid EEPROM read length",
ueye.IS_INVALID_EEPROM_WRITE_LENGTH: "Invalid EEPROM write length",
ueye.IS_INVALID_BOARD_INFO_POINTER: "Invalid board info pointer",
ueye.IS_INVALID_DISPLAY_MODE: "Invalid display mode",
ueye.IS_INVALID_ERR_REP_MODE: "Invalid error report mode",
ueye.IS_INVALID_BITS_PIXEL: "Invalid bits per pixel",
ueye.IS_INVALID_MEMORY_POINTER: "Invalid memory pointer",
ueye.IS_FILE_WRITE_OPEN_ERROR: "File write open error",
ueye.IS_FILE_READ_OPEN_ERROR: "File read open error",
ueye.IS_FILE_READ_INVALID_BMP_ID: "File read invalid BMP ID",
ueye.IS_FILE_READ_INVALID_BMP_SIZE: "File read invalid BMP size",
ueye.IS_FILE_READ_INVALID_BIT_COUNT: "File read invalid bit count",
ueye.IS_WRONG_KERNEL_VERSION: "Wrong kernel version",
ueye.IS_RISC_INVALID_XLENGTH: "RISC invalid X length",
ueye.IS_RISC_INVALID_YLENGTH: "RISC invalid Y length",
ueye.IS_RISC_EXCEED_IMG_SIZE: "RISC exceed image size",
ueye.IS_DD_MAIN_FAILED: "DirectDraw main surface failed",
ueye.IS_DD_PRIMSURFACE_FAILED: "DirectDraw primary surface failed",
ueye.IS_DD_SCRN_SIZE_NOT_SUPPORTED: "Screen size not supported",
ueye.IS_DD_CLIPPER_FAILED: "Clipper failed",
ueye.IS_DD_CLIPPER_HWND_FAILED: "Clipper HWND failed",
ueye.IS_DD_CLIPPER_CONNECT_FAILED: "Clipper connect failed",
ueye.IS_DD_BACKSURFACE_FAILED: "Backsurface failed",
ueye.IS_DD_BACKSURFACE_IN_SYSMEM: "Backsurface in system memory",
ueye.IS_DD_MDL_MALLOC_ERR: "Memory malloc error",
ueye.IS_DD_MDL_SIZE_ERR: "Memory size error",
ueye.IS_DD_CLIP_NO_CHANGE: "Clip no change",
ueye.IS_DD_PRIMMEM_NULL: "Primary memory null",
ueye.IS_DD_BACKMEM_NULL: "Back memory null",
ueye.IS_DD_BACKOVLMEM_NULL: "Back overlay memory null",
ueye.IS_DD_OVERLAYSURFACE_FAILED: "Overlay surface failed",
ueye.IS_DD_OVERLAYSURFACE_IN_SYSMEM: "Overlay surface in system memory",
ueye.IS_DD_OVERLAY_NOT_ALLOWED: "Overlay not allowed",
ueye.IS_DD_OVERLAY_COLKEY_ERR: "Overlay color key error",
ueye.IS_DD_OVERLAY_NOT_ENABLED: "Overlay not enabled",
ueye.IS_DD_GET_DC_ERROR: "Get DC error",
ueye.IS_DD_DDRAW_DLL_NOT_LOADED: "DirectDraw DLL not loaded",
ueye.IS_DD_THREAD_NOT_CREATED: "DirectDraw thread not created",
ueye.IS_DD_CANT_GET_CAPS: "Cannot get capabilities",
ueye.IS_DD_NO_OVERLAYSURFACE: "No overlay surface",
ueye.IS_DD_NO_OVERLAYSTRETCH: "No overlay stretch",
ueye.IS_DD_CANT_CREATE_OVERLAYSURFACE: "Cannot create overlay surface",
ueye.IS_DD_CANT_UPDATE_OVERLAYSURFACE: "Cannot update overlay surface",
ueye.IS_DD_INVALID_STRETCH: "Invalid stretch",
ueye.IS_EV_INVALID_EVENT_NUMBER: "Invalid event number",
ueye.IS_INVALID_MODE: "Invalid mode",
ueye.IS_CANT_FIND_HOOK: "Cannot find hook",
ueye.IS_CANT_GET_HOOK_PROC_ADDR: "Cannot get hook procedure address",
ueye.IS_CANT_CHAIN_HOOK_PROC: "Cannot chain hook procedure",
ueye.IS_CANT_SETUP_WND_PROC: "Cannot setup window procedure",
ueye.IS_HWND_NULL: "HWND is null",
ueye.IS_INVALID_UPDATE_MODE: "Invalid update mode",
ueye.IS_NO_ACTIVE_IMG_MEM: "No active image memory",
ueye.IS_CANT_INIT_EVENT: "Cannot initialize event",
ueye.IS_FUNC_NOT_AVAIL_IN_OS: "Function not available in OS",
ueye.IS_CAMERA_NOT_CONNECTED: "Camera not connected",
ueye.IS_SEQUENCE_LIST_EMPTY: "Sequence list empty",
ueye.IS_CANT_ADD_TO_SEQUENCE: "Cannot add to sequence",
ueye.IS_LOW_OF_SEQUENCE_RISC_MEM: "Low sequence RISC memory",
ueye.IS_IMGMEM2FREE_USED_IN_SEQ: "Image memory to free used in sequence",
ueye.IS_IMGMEM_NOT_IN_SEQUENCE_LIST: "Image memory not in sequence list",
ueye.IS_SEQUENCE_BUF_ALREADY_LOCKED: "Sequence buffer already locked",
ueye.IS_INVALID_DEVICE_ID: "Invalid device ID",
ueye.IS_INVALID_BOARD_ID: "Invalid board ID",
ueye.IS_ALL_DEVICES_BUSY: "All devices busy",
ueye.IS_HOOK_BUSY: "Hook busy",
ueye.IS_TIMED_OUT: "Timed out",
ueye.IS_NULL_POINTER: "Null pointer",
ueye.IS_WRONG_HOOK_VERSION: "Wrong hook version",
ueye.IS_INVALID_PARAMETER: "Invalid parameter",
ueye.IS_NOT_ALLOWED: "Not allowed",
ueye.IS_OUT_OF_MEMORY: "Out of memory",
ueye.IS_INVALID_WHILE_LIVE: "Invalid while live",
ueye.IS_ACCESS_VIOLATION: "Access violation",
ueye.IS_UNKNOWN_ROP_EFFECT: "Unknown ROP effect",
ueye.IS_INVALID_RENDER_MODE: "Invalid render mode",
ueye.IS_INVALID_THREAD_CONTEXT: "Invalid thread context",
ueye.IS_NO_HARDWARE_INSTALLED: "No hardware installed",
ueye.IS_INVALID_WATCHDOG_TIME: "Invalid watchdog time",
ueye.IS_INVALID_WATCHDOG_MODE: "Invalid watchdog mode",
ueye.IS_INVALID_PASSTHROUGH_IN: "Invalid passthrough input",
ueye.IS_ERROR_SETTING_PASSTHROUGH_IN: "Error setting passthrough input",
ueye.IS_FAILURE_ON_SETTING_WATCHDOG: "Failure setting watchdog",
ueye.IS_NO_USB20: "No USB 2.0",
ueye.IS_CAPTURE_RUNNING: "Capture running",
ueye.IS_MEMORY_BOARD_ACTIVATED: "Memory board activated",
ueye.IS_MEMORY_BOARD_DEACTIVATED: "Memory board deactivated",
ueye.IS_NO_MEMORY_BOARD_CONNECTED: "No memory board connected",
ueye.IS_TOO_LESS_MEMORY: "Too little memory",
ueye.IS_IMAGE_NOT_PRESENT: "Image not present",
ueye.IS_MEMORY_MODE_RUNNING: "Memory mode running",
ueye.IS_MEMORYBOARD_DISABLED: "Memoryboard disabled",
ueye.IS_TRIGGER_ACTIVATED: "Trigger activated",
ueye.IS_WRONG_KEY: "Wrong key",
ueye.IS_CRC_ERROR: "CRC error",
ueye.IS_NOT_YET_RELEASED: "Not yet released",
ueye.IS_NOT_CALIBRATED: "Not calibrated", # already present
ueye.IS_WAITING_FOR_KERNEL: "Waiting for kernel",
ueye.IS_NOT_SUPPORTED: "Not supported", # already present
ueye.IS_TRIGGER_NOT_ACTIVATED: "Trigger not activated",
ueye.IS_OPERATION_ABORTED: "Operation aborted",
ueye.IS_BAD_STRUCTURE_SIZE: "Bad structure size",
ueye.IS_INVALID_BUFFER_SIZE: "Invalid buffer size",
ueye.IS_INVALID_PIXEL_CLOCK: "Invalid pixel clock",
ueye.IS_INVALID_EXPOSURE_TIME: "Invalid exposure time",
ueye.IS_AUTO_EXPOSURE_RUNNING: "Auto exposure running",
ueye.IS_CANNOT_CREATE_BB_SURF: "Cannot create BB surface",
ueye.IS_CANNOT_CREATE_BB_MIX: "Cannot create BB mix",
ueye.IS_BB_OVLMEM_NULL: "BB overlay memory null",
ueye.IS_CANNOT_CREATE_BB_OVL: "Cannot create BB overlay",
ueye.IS_NOT_SUPP_IN_OVL_SURF_MODE: "Not supported in overlay surface mode",
ueye.IS_INVALID_SURFACE: "Invalid surface",
ueye.IS_SURFACE_LOST: "Surface lost",
ueye.IS_RELEASE_BB_OVL_DC: "Release BB overlay DC",
ueye.IS_BB_TIMER_NOT_CREATED: "BB timer not created",
ueye.IS_BB_OVL_NOT_EN: "BB overlay not enabled",
ueye.IS_ONLY_IN_BB_MODE: "Only in BB mode",
ueye.IS_INVALID_COLOR_FORMAT: "Invalid color format",
ueye.IS_INVALID_WB_BINNING_MODE: "Invalid WB binning mode",
ueye.IS_INVALID_I2C_DEVICE_ADDRESS: "Invalid I²C device address",
ueye.IS_COULD_NOT_CONVERT: "Could not convert",
ueye.IS_TRANSFER_ERROR: "Transfer error", # already present
ueye.IS_PARAMETER_SET_NOT_PRESENT: "Parameter set not present",
ueye.IS_INVALID_CAMERA_TYPE: "Invalid camera type",
ueye.IS_INVALID_HOST_IP_HIBYTE: "Invalid host IP high byte",
ueye.IS_CM_NOT_SUPP_IN_CURR_DISPLAYMODE: "Color matrix not supported in current display mode",
ueye.IS_NO_IR_FILTER: "No IR filter",
ueye.IS_STARTER_FW_UPLOAD_NEEDED: "Starter firmware upload needed",
ueye.IS_DR_LIBRARY_NOT_FOUND: "Driver library not found",
ueye.IS_DR_DEVICE_OUT_OF_MEMORY: "Driver device out of memory",
ueye.IS_DR_CANNOT_CREATE_SURFACE: "Driver cannot create surface",
ueye.IS_DR_CANNOT_CREATE_VERTEX_BUFFER: "Driver cannot create vertex buffer",
ueye.IS_DR_CANNOT_CREATE_TEXTURE: "Driver cannot create texture",
ueye.IS_DR_CANNOT_LOCK_OVERLAY_SURFACE: "Driver cannot lock overlay surface",
ueye.IS_DR_CANNOT_UNLOCK_OVERLAY_SURFACE: "Driver cannot unlock overlay surface",
ueye.IS_DR_CANNOT_GET_OVERLAY_DC: "Driver cannot get overlay DC",
ueye.IS_DR_CANNOT_RELEASE_OVERLAY_DC: "Driver cannot release overlay DC",
ueye.IS_DR_DEVICE_CAPS_INSUFFICIENT: "Driver device capabilities insufficient",
ueye.IS_INCOMPATIBLE_SETTING: "Incompatible setting",
ueye.IS_DR_NOT_ALLOWED_WHILE_DC_IS_ACTIVE: "Driver not allowed while DC is active",
ueye.IS_DEVICE_ALREADY_PAIRED: "Device already paired",
ueye.IS_SUBNETMASK_MISMATCH: "Subnet mask mismatch",
ueye.IS_SUBNET_MISMATCH: "Subnet mismatch",
ueye.IS_INVALID_IP_CONFIGURATION: "Invalid IP configuration",
ueye.IS_DEVICE_NOT_COMPATIBLE: "Device not compatible",
ueye.IS_NETWORK_FRAME_SIZE_INCOMPATIBLE: "Network frame size incompatible",
ueye.IS_NETWORK_CONFIGURATION_INVALID: "Network configuration invalid",
ueye.IS_ERROR_CPU_IDLE_STATES_CONFIGURATION: "CPU idle states configuration error",
ueye.IS_DEVICE_BUSY: "Device busy",
ueye.IS_SENSOR_INITIALIZATION_FAILED: "Sensor initialization failed",
ueye.IS_IMAGE_BUFFER_NOT_DWORD_ALIGNED: "Image buffer not DWORD aligned",
ueye.IS_SEQ_BUFFER_IS_LOCKED: "Sequence buffer is locked",
ueye.IS_FILE_PATH_DOES_NOT_EXIST: "File path does not exist",
ueye.IS_INVALID_WINDOW_HANDLE: "Invalid window handle",
ueye.IS_INVALID_IMAGE_PARAMETER: "Invalid image parameter",
ueye.IS_NO_SUCH_DEVICE: "No such device",
ueye.IS_DEVICE_IN_USE: "Device in use",
}
bits_per_pixel = {
ueye.IS_CM_SENSOR_RAW8: 8,
ueye.IS_CM_SENSOR_RAW10: 16,
ueye.IS_CM_SENSOR_RAW12: 16,
ueye.IS_CM_SENSOR_RAW16: 16,
ueye.IS_CM_MONO8: 8,
ueye.IS_CM_RGB8_PACKED: 24,
ueye.IS_CM_BGR8_PACKED: 24,
ueye.IS_CM_RGBA8_PACKED: 32,
ueye.IS_CM_BGRA8_PACKED: 32,
ueye.IS_CM_BGR10_PACKED: 32,
ueye.IS_CM_RGB10_PACKED: 32,
ueye.IS_CM_BGRA12_UNPACKED: 64,
ueye.IS_CM_BGR12_UNPACKED: 48,
ueye.IS_CM_BGRY8_PACKED: 32,
ueye.IS_CM_BGR565_PACKED: 16,
ueye.IS_CM_BGR5_PACKED: 16,
ueye.IS_CM_UYVY_PACKED: 16,
ueye.IS_CM_UYVY_MONO_PACKED: 16,
ueye.IS_CM_UYVY_BAYER_PACKED: 16,
ueye.IS_CM_CBYCRY_PACKED: 16,
}
else:
error_codes = {}
bits_per_pixel = {}
def get_bits_per_pixel(color_mode):
"""
Returns the number of bits per pixel for the given color mode.
"""
if color_mode not in bits_per_pixel:
raise UEyeException(f"Unknown color mode: {color_mode}")
return bits_per_pixel[color_mode]
class UEyeException(Exception):
"""Custom exception for uEye errors."""
def __init__(self, error_code, called_from: str | None = None):
self.error_code = error_code
self.called_from = called_from if called_from is not None else ""
def __str__(self):
if self.error_code in error_codes:
return f"Exception: {error_codes[self.error_code]} raised in {self.called_from}."
else:
for att, val in ueye.__dict__.items():
if (
att[0:2] == "IS"
and val == self.error_code
and ("FAILED" in att or "INVALID" in att or "ERROR" in att or "NOT" in att)
):
return f"Exception: {str(self.error_code)} ({att} ? <value> {val}) raised in {self.called_from}."
return f"Exception: {str(self.error_code)} raised in {self.called_from}."
def check_error(error_code, called_from: str | None = None):
"""
Check an error code, and raise an error if adequate.
"""
if error_code != ueye.IS_SUCCESS:
called_from = called_from if called_from is not None else ""
raise UEyeException(error_code, called_from)

View File

@@ -1,479 +1,233 @@
"""IDS Camera class for cSAXS IDS cameras."""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING, Literal, Tuple, TypedDict
import numpy as np
from bec_lib import messages
from bec_lib.logger import bec_logger
from ophyd import Component as Cpt
from ophyd import Device, Kind
from ophyd_devices.interfaces.base_classes.psi_detector_base import (
CustomDetectorMixin,
PSIDetectorBase,
)
from ophyd_devices.sim.sim_signals import SetableSignal
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from ophyd_devices.utils.bec_signals import AsyncSignal, PreviewSignal
try:
from pyueye import ueye
except ImportError:
# The pyueye library is not installed or doesn't provide the necessary c libs
ueye = None
from csaxs_bec.devices.ids_cameras.base_integration.camera import Camera
if TYPE_CHECKING:
from bec_lib.devicemanager import ScanInfo
from pydantic import ValidationInfo
logger = bec_logger.logger
class IDSCustomPrepare(CustomDetectorMixin):
class IDSCamera(PSIDeviceBase):
"""IDS Camera class for cSAXS.
USER_ACCESS = ["pyueye"]
pyueye = ueye
This class inherits from PSIDeviceBase and implements the necessary methods
to interact with the IDS camera using the pyueye library.
"""
def __init__(self, *_args, parent: Device = None, **_kwargs) -> None:
super().__init__(*_args, parent=parent, **_kwargs)
self.ueye = ueye
self.h_cam = None
self.s_info = None
self.data_thread = None
self.thread_event = None
image = Cpt(
PreviewSignal,
name="image",
ndim=2,
doc="Preview signal for the camera.",
num_rotation_90=0,
transpose=False,
)
roi_signal = Cpt(
AsyncSignal,
name="roi_signal",
ndim=0,
max_size=1000,
doc="Signal for the region of interest (ROI).",
async_update={"type": "add", "max_shape": [None]},
)
def on_connection_established(self):
self.hCam = self.ueye.HIDS(
self.parent.camera_ID
) # 0: first available camera; 1-254: The camera with the specified camera ID
self.sInfo = self.ueye.SENSORINFO()
self.cInfo = self.ueye.CAMINFO()
self.pcImageMemory = self.ueye.c_mem_p()
self.MemID = self.ueye.int()
self.rectAOI = self.ueye.IS_RECT()
self.pitch = self.ueye.INT()
self.nBitsPerPixel = self.ueye.INT(
self.parent.bits_per_pixel
) # 24: bits per pixel for color mode; take 8 bits per pixel for monochrome
self.channels = (
self.parent.channels
) # 3: channels for color mode(RGB); take 1 channel for monochrome
self.m_nColorMode = self.ueye.INT(
self.parent.m_n_colormode
) # Y8/RGB16/RGB24/REG32 (1 for our color cameras)
self.bytes_per_pixel = int(self.nBitsPerPixel / 8)
# Starts the driver and establishes the connection to the camera
nRet = self.ueye.is_InitCamera(self.hCam, None)
if nRet != self.ueye.IS_SUCCESS:
print("is_InitCamera ERROR")
# Reads out the data hard-coded in the non-volatile camera memory and writes it to the data structure that cInfo points to
nRet = self.ueye.is_GetCameraInfo(self.hCam, self.cInfo)
if nRet != self.ueye.IS_SUCCESS:
print("is_GetCameraInfo ERROR")
# You can query additional information about the sensor type used in the camera
nRet = self.ueye.is_GetSensorInfo(self.hCam, self.sInfo)
if nRet != self.ueye.IS_SUCCESS:
print("is_GetSensorInfo ERROR")
nRet = self.ueye.is_ResetToDefault(self.hCam)
if nRet != self.ueye.IS_SUCCESS:
print("is_ResetToDefault ERROR")
# Set display mode to DIB
nRet = self.ueye.is_SetDisplayMode(self.hCam, self.ueye.IS_SET_DM_DIB)
# Set the right color mode
if (
int.from_bytes(self.sInfo.nColorMode.value, byteorder="big")
== self.ueye.IS_COLORMODE_BAYER
):
# setup the color depth to the current windows setting
self.ueye.is_GetColorDepth(self.hCam, self.nBitsPerPixel, self.m_nColorMode)
bytes_per_pixel = int(self.nBitsPerPixel / 8)
print("IS_COLORMODE_BAYER: ")
print("\tm_nColorMode: \t\t", self.m_nColorMode)
print("\tnBitsPerPixel: \t\t", self.nBitsPerPixel)
print("\tbytes_per_pixel: \t\t", bytes_per_pixel)
print()
elif (
int.from_bytes(self.sInfo.nColorMode.value, byteorder="big")
== self.ueye.IS_COLORMODE_CBYCRY
):
# for color camera models use RGB32 mode
m_nColorMode = ueye.IS_CM_BGRA8_PACKED
nBitsPerPixel = ueye.INT(32)
bytes_per_pixel = int(self.nBitsPerPixel / 8)
print("IS_COLORMODE_CBYCRY: ")
print("\tm_nColorMode: \t\t", m_nColorMode)
print("\tnBitsPerPixel: \t\t", nBitsPerPixel)
print("\tbytes_per_pixel: \t\t", bytes_per_pixel)
print()
elif (
int.from_bytes(self.sInfo.nColorMode.value, byteorder="big")
== self.ueye.IS_COLORMODE_MONOCHROME
):
# for color camera models use RGB32 mode
m_nColorMode = self.ueye.IS_CM_MONO8
nBitsPerPixel = self.ueye.INT(8)
bytes_per_pixel = int(nBitsPerPixel / 8)
print("IS_COLORMODE_MONOCHROME: ")
print("\tm_nColorMode: \t\t", m_nColorMode)
print("\tnBitsPerPixel: \t\t", nBitsPerPixel)
print("\tbytes_per_pixel: \t\t", bytes_per_pixel)
print()
else:
# for monochrome camera models use Y8 mode
m_nColorMode = self.ueye.IS_CM_MONO8
nBitsPerPixel = self.ueye.INT(8)
bytes_per_pixel = int(nBitsPerPixel / 8)
print("else")
# Can be used to set the size and position of an "area of interest"(AOI) within an image
nRet = self.ueye.is_AOI(
self.hCam, ueye.IS_AOI_IMAGE_GET_AOI, self.rectAOI, self.ueye.sizeof(self.rectAOI)
)
if nRet != self.ueye.IS_SUCCESS:
print("is_AOI ERROR")
self.width = self.rectAOI.s32Width
self.height = self.rectAOI.s32Height
# Prints out some information about the camera and the sensor
print("Camera model:\t\t", self.sInfo.strSensorName.decode("utf-8"))
print("Camera serial no.:\t", self.cInfo.SerNo.decode("utf-8"))
print("Maximum image width:\t", self.width)
print("Maximum image height:\t", self.height)
print()
# ---------------------------------------------------------------------------------------------------------------------------------------
# Allocates an image memory for an image having its dimensions defined by width and height and its color depth defined by nBitsPerPixel
nRet = self.ueye.is_AllocImageMem(
self.hCam, self.width, self.height, self.nBitsPerPixel, self.pcImageMemory, self.MemID
)
if nRet != self.ueye.IS_SUCCESS:
print("is_AllocImageMem ERROR")
else:
# Makes the specified image memory the active memory
nRet = self.ueye.is_SetImageMem(self.hCam, self.pcImageMemory, self.MemID)
if nRet != self.ueye.IS_SUCCESS:
print("is_SetImageMem ERROR")
else:
# Set the desired color mode
nRet = self.ueye.is_SetColorMode(self.hCam, self.m_nColorMode)
# Activates the camera's live video mode (free run mode)
nRet = self.ueye.is_CaptureVideo(self.hCam, self.ueye.IS_DONT_WAIT)
if nRet != self.ueye.IS_SUCCESS:
print("is_CaptureVideo ERROR")
# Enables the queue mode for existing image memory sequences
nRet = self.ueye.is_InquireImageMem(
self.hCam,
self.pcImageMemory,
self.MemID,
self.width,
self.height,
self.nBitsPerPixel,
self.pitch,
)
if nRet != self.ueye.IS_SUCCESS:
print("is_InquireImageMem ERROR")
else:
print("Press q to leave the programm")
startmeasureframerate = True
Gain = False
# Start live mode of camera immediately
self.parent.start_live_mode()
def _start_data_thread(self):
self.thread_event = threading.Event()
self.data_thread = threading.Thread(target=self._receive_data_from_camera, daemon=True)
self.data_thread.start()
def _receive_data_from_camera(self):
while not self.thread_event.is_set():
# In order to display the image in an OpenCV window we need to...
# ...extract the data of our image memory
array = ueye.get_data(
self.pcImageMemory,
self.width,
self.height,
self.nBitsPerPixel,
self.pitch,
copy=False,
)
# bytes_per_pixel = int(nBitsPerPixel / 8)
# ...reshape it in an numpy array...
frame = np.reshape(array, (self.height.value, self.width.value, self.bytes_per_pixel))
self.parent.image_data.put(frame)
self.parent._run_subs(sub_type=self.parent.SUB_MONITOR, value=frame)
time.sleep(0.1)
def on_trigger(self):
pass
# self.parent._run_subs(sub_type=self.parent.SUB_MONITOR, value=self.parent.image_data.get())
class IDSCamera(PSIDetectorBase):
USER_ACCESS = ["start_live_mode", "stop_live_mode"]
custom_prepare_cls = IDSCustomPrepare
image_data = Cpt(SetableSignal, value=np.empty((100, 100)), kind=Kind.omitted)
SUB_MONITOR = "device_monitor_2d"
_default_sub = SUB_MONITOR
USER_ACCESS = ["live_mode", "mask", "set_rect_roi", "get_last_image"]
def __init__(
self,
prefix="",
*,
name: str,
camera_ID: int,
bits_per_pixel: int,
channels: int,
m_n_colormode: int,
kind=None,
parent=None,
device_manager=None,
camera_id: int,
prefix: str = "",
scan_info: ScanInfo | None = None,
m_n_colormode: Literal[0, 1, 2, 3] = 1,
bits_per_pixel: Literal[8, 24] = 24,
live_mode: bool = False,
num_rotation_90: int = 0,
transpose: bool = False,
force_monochrome: bool = False,
**kwargs,
):
super().__init__(
prefix, name=name, kind=kind, parent=parent, device_manager=device_manager, **kwargs
"""Initialize the IDS Camera.
Args:
name (str): Name of the device.
camera_id (int): The ID of the camera device.
prefix (str): Prefix for the device.
scan_info (ScanInfo | None): Scan information for the device.
m_n_colormode (Literal[0, 1, 2, 3]): Color mode for the camera.
bits_per_pixel (Literal[8, 24]): Number of bits per pixel for the camera.
live_mode (bool): Whether to enable live mode for the camera.
"""
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
self._live_mode_thread: threading.Thread | None = None
self._stop_live_mode_event: threading.Event = threading.Event()
self.cam = Camera(
camera_id=camera_id,
m_n_colormode=m_n_colormode,
bits_per_pixel=bits_per_pixel,
connect=False,
)
self.camera_ID = camera_ID
self.bits_per_pixel = bits_per_pixel
self.channels = channels
self.m_n_colormode = m_n_colormode
#TODO fix connected and wait_for_connection
self.custom_prepare.on_connection_established()
self._live_mode = False
self._inputs = {"live_mode": live_mode}
self._mask = np.zeros((1, 1), dtype=np.uint8)
self.image.num_rotation_90 = num_rotation_90
self.image.transpose = transpose
self._force_monochrome = force_monochrome
def wait_for_connection(self, all_signals=False, timeout=10):
if ueye is None:
raise ImportError(
"The pyueye library is not installed or doesn't provide the necessary c libs"
############## Live Mode Methods ##############
@property
def mask(self) -> np.ndarray:
"""Return the current region of interest (ROI) for the camera."""
return self._mask
@mask.setter
def mask(self, value: np.ndarray):
"""
Set the region of interest (ROI) for the camera.
Args:
value (np.ndarray): The mask to set as the ROI.
"""
if value.ndim != 2:
raise ValueError("ROI mask must be a 2D array.")
img_shape = (self.cam.cam.height.value, self.cam.cam.width.value)
if value.shape[0] != img_shape[0] or value.shape[1] != img_shape[1]:
raise ValueError(
f"ROI mask shape {value.shape} does not match image shape {img_shape}."
)
super().wait_for_connection(all_signals, timeout)
#self.custom_prepare.on_connection_established()
self._mask = value
def destroy(self):
"""Extend Ophyds destroy function to kill the data thread"""
self.stop_live_mode()
super().destroy()
@property
def live_mode(self) -> bool:
"""Return whether the camera is in live mode."""
return self._live_mode
def start_live_mode(self):
if self.custom_prepare.data_thread is not None:
self.stop_live_mode()
self.custom_prepare._start_data_thread()
@live_mode.setter
def live_mode(self, value: bool):
"""Set the live mode for the camera."""
if value != self._live_mode:
if self.cam._connected is False: # $ pylint: disable=protected-access
self.cam.on_connect()
self._live_mode = value
if value:
self._start_live()
else:
self._stop_live()
def stop_live_mode(self):
"""Stopping the camera live mode."""
if self.custom_prepare.thread_event is not None:
self.custom_prepare.thread_event.set()
if self.custom_prepare.data_thread is not None:
self.custom_prepare.data_thread.join()
self.custom_prepare.thread_event = None
self.custom_prepare.data_thread = None
def set_rect_roi(self, x: int, y: int, width: int, height: int):
"""Set the rectangular region of interest (ROI) for the camera."""
if x < 0 or y < 0 or width <= 0 or height <= 0:
raise ValueError("ROI coordinates and dimensions must be positive integers.")
img_shape = (self.cam.cam.height.value, self.cam.cam.width.value)
if x + width > img_shape[1] or y + height > img_shape[0]:
raise ValueError("ROI exceeds camera dimensions.")
mask = np.zeros(img_shape, dtype=np.uint8)
mask[y : y + height, x : x + width] = 1
self.mask = mask
def _start_live(self):
"""Start the live mode for the camera."""
if self._live_mode_thread is not None:
logger.info("Live mode thread is already running.")
return
self._stop_live_mode_event.clear()
self._live_mode_thread = threading.Thread(
target=self._live_mode_loop, args=(self._stop_live_mode_event,)
)
self._live_mode_thread.start()
"""from pyueye import ueye
import numpy as np
import cv2
import sys
import time
#---------------------------------------------------------------------------------------------------------------------------------------
#Variables
hCam = ueye.HIDS(202) #0: first available camera; 1-254: The camera with the specified camera ID
sInfo = ueye.SENSORINFO()
cInfo = ueye.CAMINFO()
pcImageMemory = ueye.c_mem_p()
MemID = ueye.int()
rectAOI = ueye.IS_RECT()
pitch = ueye.INT()
nBitsPerPixel = ueye.INT(24) #24: bits per pixel for color mode; take 8 bits per pixel for monochrome
channels = 3 #3: channels for color mode(RGB); take 1 channel for monochrome
m_nColorMode = ueye.INT(1) # Y8/RGB16/RGB24/REG32 (1 for our color cameras)
bytes_per_pixel = int(nBitsPerPixel / 8)
ids_cam
...
deviceConfig:
camera_ID: 202
bits_per_pixel: 24
channels: 3
m_n_colormode: 1
#---------------------------------------------------------------------------------------------------------------------------------------
print("START")
print()
# Starts the driver and establishes the connection to the camera
nRet = ueye.is_InitCamera(hCam, None)
if nRet != ueye.IS_SUCCESS:
print("is_InitCamera ERROR")
# Reads out the data hard-coded in the non-volatile camera memory and writes it to the data structure that cInfo points to
nRet = ueye.is_GetCameraInfo(hCam, cInfo)
if nRet != ueye.IS_SUCCESS:
print("is_GetCameraInfo ERROR")
# You can query additional information about the sensor type used in the camera
nRet = ueye.is_GetSensorInfo(hCam, sInfo)
if nRet != ueye.IS_SUCCESS:
print("is_GetSensorInfo ERROR")
nRet = ueye.is_ResetToDefault( hCam)
if nRet != ueye.IS_SUCCESS:
print("is_ResetToDefault ERROR")
# Set display mode to DIB
nRet = ueye.is_SetDisplayMode(hCam, ueye.IS_SET_DM_DIB)
# Set the right color mode
if int.from_bytes(sInfo.nColorMode.value, byteorder='big') == ueye.IS_COLORMODE_BAYER:
# setup the color depth to the current windows setting
ueye.is_GetColorDepth(hCam, nBitsPerPixel, m_nColorMode)
bytes_per_pixel = int(nBitsPerPixel / 8)
print("IS_COLORMODE_BAYER: ", )
print("\tm_nColorMode: \t\t", m_nColorMode)
print("\tnBitsPerPixel: \t\t", nBitsPerPixel)
print("\tbytes_per_pixel: \t\t", bytes_per_pixel)
print()
elif int.from_bytes(sInfo.nColorMode.value, byteorder='big') == ueye.IS_COLORMODE_CBYCRY:
# for color camera models use RGB32 mode
m_nColorMode = ueye.IS_CM_BGRA8_PACKED
nBitsPerPixel = ueye.INT(32)
bytes_per_pixel = int(nBitsPerPixel / 8)
print("IS_COLORMODE_CBYCRY: ", )
print("\tm_nColorMode: \t\t", m_nColorMode)
print("\tnBitsPerPixel: \t\t", nBitsPerPixel)
print("\tbytes_per_pixel: \t\t", bytes_per_pixel)
print()
elif int.from_bytes(sInfo.nColorMode.value, byteorder='big') == ueye.IS_COLORMODE_MONOCHROME:
# for color camera models use RGB32 mode
m_nColorMode = ueye.IS_CM_MONO8
nBitsPerPixel = ueye.INT(8)
bytes_per_pixel = int(nBitsPerPixel / 8)
print("IS_COLORMODE_MONOCHROME: ", )
print("\tm_nColorMode: \t\t", m_nColorMode)
print("\tnBitsPerPixel: \t\t", nBitsPerPixel)
print("\tbytes_per_pixel: \t\t", bytes_per_pixel)
print()
else:
# for monochrome camera models use Y8 mode
m_nColorMode = ueye.IS_CM_MONO8
nBitsPerPixel = ueye.INT(8)
bytes_per_pixel = int(nBitsPerPixel / 8)
print("else")
# Can be used to set the size and position of an "area of interest"(AOI) within an image
nRet = ueye.is_AOI(hCam, ueye.IS_AOI_IMAGE_GET_AOI, rectAOI, ueye.sizeof(rectAOI))
if nRet != ueye.IS_SUCCESS:
print("is_AOI ERROR")
width = rectAOI.s32Width
height = rectAOI.s32Height
# Prints out some information about the camera and the sensor
print("Camera model:\t\t", sInfo.strSensorName.decode('utf-8'))
print("Camera serial no.:\t", cInfo.SerNo.decode('utf-8'))
print("Maximum image width:\t", width)
print("Maximum image height:\t", height)
print()
#---------------------------------------------------------------------------------------------------------------------------------------
# Allocates an image memory for an image having its dimensions defined by width and height and its color depth defined by nBitsPerPixel
nRet = ueye.is_AllocImageMem(hCam, width, height, nBitsPerPixel, pcImageMemory, MemID)
if nRet != ueye.IS_SUCCESS:
print("is_AllocImageMem ERROR")
else:
# Makes the specified image memory the active memory
nRet = ueye.is_SetImageMem(hCam, pcImageMemory, MemID)
if nRet != ueye.IS_SUCCESS:
print("is_SetImageMem ERROR")
else:
# Set the desired color mode
nRet = ueye.is_SetColorMode(hCam, m_nColorMode)
# Activates the camera's live video mode (free run mode)
nRet = ueye.is_CaptureVideo(hCam, ueye.IS_DONT_WAIT)
if nRet != ueye.IS_SUCCESS:
print("is_CaptureVideo ERROR")
# Enables the queue mode for existing image memory sequences
nRet = ueye.is_InquireImageMem(hCam, pcImageMemory, MemID, width, height, nBitsPerPixel, pitch)
if nRet != ueye.IS_SUCCESS:
print("is_InquireImageMem ERROR")
else:
print("Press q to leave the programm")
startmeasureframerate=True
Gain = False
#---------------------------------------------------------------------------------------------------------------------------------------
# Continuous image display
while(nRet == ueye.IS_SUCCESS):
# In order to display the image in an OpenCV window we need to...
# ...extract the data of our image memory
array = ueye.get_data(pcImageMemory, width, height, nBitsPerPixel, pitch, copy=False)
# bytes_per_pixel = int(nBitsPerPixel / 8)
# ...reshape it in an numpy array...
frame = np.reshape(array,(height.value, width.value, bytes_per_pixel))
# ...resize the image by a half
frame = cv2.resize(frame,(0,0),fx=0.5, fy=0.5)
#---------------------------------------------------------------------------------------------------------------------------------------
#Include image data processing here
#---------------------------------------------------------------------------------------------------------------------------------------
#...and finally display it
cv2.imshow("SimpleLive_Python_uEye_OpenCV", frame)
if startmeasureframerate:
starttime = time.time()
startmeasureframerate=False
framenumber=0
if time.time() > starttime+5:
print(f"Caught {framenumber/5} frames per second")
startmeasureframerate=True
Gain = ~Gain
if Gain:
nRet = ueye.is_SetGainBoost(hCam, 1)
def _stop_live(self):
"""Stop the live mode for the camera."""
if self._live_mode_thread is None:
logger.info("Live mode thread is not running.")
return
self._stop_live_mode_event.set()
self._live_mode_thread.join(timeout=5)
if self._live_mode_thread.is_alive():
logger.warning("Live mode thread did not stop gracefully.")
else:
nRet = ueye.is_SetGainBoost(hCam, 0)
print(f"Gain setting status {nRet}")
#...and finally display it
cv2.imshow("SimpleLive_Python_uEye_OpenCV", frame)
framenumber+=1
time.sleep(0.1)
self._live_mode_thread = None
logger.info("Live mode stopped.")
# Press q if you want to end the loop
if (cv2.waitKey(1) & 0xFF) == ord('q'):
break
#---------------------------------------------------------------------------------------------------------------------------------------
def _live_mode_loop(self, stop_event: threading.Event):
"""Loop to capture images in live mode."""
self.cam.set_camera_rate_limiting(True)
while not stop_event.is_set():
try:
self.process_data(self.cam.get_image_data())
except Exception as e:
logger.error(f"Error in live mode loop: {e}")
break
stop_event.wait(0.2) # 5 Hz
self.cam.set_camera_rate_limiting(False)
# Releases an image memory that was allocated using is_AllocImageMem() and removes it from the driver management
ueye.is_FreeImageMem(hCam, pcImageMemory, MemID)
def process_data(self, image: np.ndarray | None):
"""Process the image data before sending it to the preview signal."""
if image is None:
return
self.image.put(image)
# Disables the hCam camera handle and releases the data structures and memory areas taken up by the uEye camera
ueye.is_ExitCamera(hCam)
def get_last_image(self) -> np.ndarray:
"""Get the last captured image from the camera."""
image = self.image.get()
if image:
return image.data
# Destroys the OpenCv windows
cv2.destroyAllWindows()
############## User Interface Methods ##############
print()
print("END")
"""
def on_connected(self):
"""Connect to the camera."""
self.cam.force_monochrome = self._force_monochrome
self.cam.on_connect()
self.live_mode = self._inputs.get("live_mode", False)
self.set_rect_roi(0, 0, self.cam.cam.width.value, self.cam.cam.height.value)
def on_destroy(self):
"""Clean up resources when the device is destroyed."""
self.cam.on_disconnect()
super().on_destroy()
def on_trigger(self):
"""Handle the trigger event."""
if not self.live_mode:
return
image = self.image.get()
if image is not None:
image: messages.DevicePreviewMessage
if self.mask.shape[0:2] != image.data.shape[0:2]:
logger.info(
f"ROI shape does not match image shape, skipping ROI application for device {self.name}."
)
return
if len(image.data.shape) == 3:
# If the image has multiple channels, apply the mask to each channel
data = image.data * self.mask[:, :, np.newaxis] # Apply mask to the image data
n_channels = 3
else:
data = image.data * self.mask
n_channels = 1
self.roi_signal.put(np.sum(data) / (np.sum(self.mask) * n_channels))
if __name__ == "__main__":
# Example usage of the IDSCamera class
camera = IDSCamera(name="TestCamera", camera_id=201, live_mode=False)
print(f"Camera {camera.name} initialized with ID {camera.cam.camera_id}.")

View File

@@ -1,83 +0,0 @@
import time
import numpy as np
from bec_lib import bec_logger
from ophyd import Kind, Signal
from ophyd.utils import ReadOnlyError
from ophyd_devices.utils.bec_device_base import BECDeviceBase
logger = bec_logger.logger
# Readout precision for Setable/ReadOnlySignal signals
PRECISION = 3
class ReadOnlySignal(Signal):
"""Setable signal for simulated devices.
The signal will store the value in sim_state of the SimulatedData class of the parent device.
It will also return the value from sim_state when get is called. Compared to the ReadOnlySignal,
this signal can be written to.
The setable signal inherits from the Signal class of ophyd, thus the class attribute needs to be
initiated as a Component (class from ophyd).
>>> signal = SetableSignal(name="signal", parent=parent, value=0)
Parameters
----------
name (string) : Name of the signal
parent (object) : Parent object of the signal, default none.
value (any) : Initial value of the signal, default 0.
kind (int) : Kind of the signal, default Kind.normal.
precision (float) : Precision of the signal, default PRECISION.
"""
def __init__(
self,
name: str,
*args,
fcn: callable,
kind: int = Kind.normal,
precision: float = PRECISION,
**kwargs,
):
super().__init__(*args, name=name, value=value, kind=kind, **kwargs)
self._metadata.update(connected=True, write_access=False)
self._value = None
self.precision = precision
self.fcn = fcn
# pylint: disable=arguments-differ
def get(self):
"""Get the current position of the simulated device.
Core function for signal.
"""
self._value = self.fcn()
return self._value
# pylint: disable=arguments-differ
def put(self, value):
"""Put the value to the simulated device.
Core function for signal.
"""
self._update_sim_state(value)
self._value = value
def describe(self):
"""Describe the readback signal.
Core function for signal.
"""
res = super().describe()
if self.precision is not None:
res[self.name]["precision"] = self.precision
return res
@property
def timestamp(self):
"""Timestamp of the readback value"""
return self._get_timestamp()

View File

@@ -0,0 +1,318 @@
"""
Generic integration of JungfrauJoch backend with Eiger detectors
for the cSAXS beamline at the Swiss Light Source.
The WEB UI is available on http://sls-jfjoch-001:8080
NOTE: this may not be the best place to store this information. It should be migrated to
beamline documentation for debugging of Eiger & JungfrauJoch.
The JungfrauJoch server for cSAXS runs on sls-jfjoch-001.psi.ch
User with sufficient rights may use:
- sudo systemctl restart jfjoch_broker
- sudo systemctl status jfjoch_broker
to check and/or restart the broker for the JungfrauJoch server.
Some extra notes for setting up the detector:
- If the energy on JFJ is set via DetectorSettings, the variable in DatasetSettings will be ignored
- Changes in energy may take time, good to implement logic that only resets energy if needed.
- For the Eiger, the frame_time_us in DetectorSettings is ignored, only the frame_time_us in
the DatasetSettings is relevant
- The bit_depth will be adjusted automatically based on the exp_time. Here, we need to ensure
that subsequent triggers properly
consider the readout_time of the boards. For Jungfrau detectors, the difference between
count_time_us and frame_time_us is the readout_time of the boards. For the Eiger, this needs
to be taken into account during the integration.
- beam_center and detector settings are required input arguments, thus, they may be set to wrong
values for acquisitions to start. Please keep this in mind.
Hardware related notes:
- If there is an HW issue with the detector, power cycling may help.
- The sls_detector package is available on console on /sls/X12SA/data/gac-x12sa/erik/micromamba
- Run: source setup_9m.sh # Be careful, this connects to the detector, so it should not be
used during operation
- Useful commands:
- p highvoltage 0 or 150 (operational)
- g highvoltage
- # Put high voltage to 0 before power cylcing it.
- telnet bchip500
- cd power_control_user/
- ./on
- ./off
Further information that may be relevant for debugging:
JungfrauJoch - one needs to connect to the jfj-server (sls-jfjoch-001)
"""
from __future__ import annotations
import os
import time
from typing import TYPE_CHECKING, Literal
import yaml
from bec_lib.file_utils import get_full_path
from bec_lib.logger import bec_logger
from jfjoch_client.models.dataset_settings import DatasetSettings
from jfjoch_client.models.detector_settings import DetectorSettings
from jfjoch_client.models.detector_state import DetectorState
from jfjoch_client.models.detector_timing import DetectorTiming
from jfjoch_client.models.file_writer_format import FileWriterFormat
from jfjoch_client.models.file_writer_settings import FileWriterSettings
from ophyd import Component as Cpt
from ophyd import DeviceStatus
from ophyd_devices import FileEventSignal, PreviewSignal
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from csaxs_bec.devices.jungfraujoch.jungfrau_joch_client import JungfrauJochClient
from csaxs_bec.devices.jungfraujoch.jungfraujoch_preview import JungfrauJochPreview
if TYPE_CHECKING: # pragma no cover
from bec_lib.devicemanager import ScanInfo
from bec_server.device_server.device_server import DeviceManagerDS
from jfjoch_client.models.measurement_statistics import MeasurementStatistics
logger = bec_logger.logger
EIGER_READOUT_TIME_US = 500e-6 # 500 microseconds in s
class EigerError(Exception):
"""Custom exception for Eiger detector errors."""
class Eiger(PSIDeviceBase):
"""
Base integration of the Eiger1.5M and Eiger9M at cSAXS. All relevant
"""
USER_ACCESS = ["detector_distance", "beam_center"]
file_event = Cpt(FileEventSignal, name="file_event")
preview_image = Cpt(PreviewSignal, name="preview_image", ndim=2)
def __init__(
self,
name: str,
detector_name: Literal["EIGER 9M", "EIGER 8.5M (tmp)", "EIGER 1.5M"],
host: str = "http://sls-jfjoch-001",
port: int = 8080,
detector_distance: float = 100.0,
beam_center: tuple[int, int] = (0, 0),
scan_info: ScanInfo = None,
readout_time: float = EIGER_READOUT_TIME_US,
device_manager=None,
**kwargs,
):
"""
Initialize the PSI Device Base class.
Args:
name (str) : Name of the device
detector_name (str): Name of the detector. Supports ["EIGER 9M", "EIGER 8.5M (tmp)", "EIGER 1.5M"]
host (str): Hostname of the Jungfrau Joch server.
port (int): Port of the Jungfrau Joch server.
scan_info (ScanInfo): The scan info to use.
device_manager (DeviceManagerDS): The device manager to use.
**kwargs: Additional keyword arguments.
"""
super().__init__(name=name, scan_info=scan_info, device_manager=device_manager, **kwargs)
self._host = f"{host}:{port}"
self.jfj_client = JungfrauJochClient(host=self._host, parent=self)
self.jfj_preview_client = JungfrauJochPreview(
url="tcp://129.129.95.114:5400", cb=self.preview_image.put
) # IP of sls-jfjoch-001.psi.ch on port 5400 for ZMQ stream
self.device_manager = device_manager
self.detector_name = detector_name
self._detector_distance = detector_distance
self._beam_center = beam_center
self._readout_time = readout_time
self._full_path = ""
if self.device_manager is not None:
self.device_manager: DeviceManagerDS
@property
def detector_distance(self) -> float:
"""The detector distance in mm."""
return self._detector_distance
@detector_distance.setter
def detector_distance(self, value: float) -> None:
"""Set the detector distance in mm."""
if value <= 0:
raise ValueError("Detector distance must be a positive value.")
self._detector_distance = value
@property
def beam_center(self) -> tuple[float, float]:
"""The beam center in pixels. (x,y)"""
return self._beam_center
@beam_center.setter
def beam_center(self, value: tuple[float, float]) -> None:
"""Set the beam center in pixels. (x,y)"""
self._beam_center = value
def on_init(self) -> None:
"""
Called when the device is initialized.
No siganls are connected at this point,
thus should not be set here but in on_connected instead.
"""
def on_connected(self) -> None:
"""
Called after the device is connected and its signals are connected.
Default values for signals should be set here.
"""
logger.debug(f"On connected called for {self.name}")
self.jfj_client.stop(request_timeout=3)
# Check which detector is selected
# Get available detectors
available_detectors = self.jfj_client.api.config_select_detector_get(_request_timeout=5)
# Get current detector
current_detector_name = ""
if available_detectors.current_id:
detector_selection = [
det.description
for det in available_detectors.detectors
if det.id == available_detectors.current_id
]
current_detector_name = detector_selection[0] if detector_selection else ""
if current_detector_name != self.detector_name:
raise RuntimeError(
f"Please select and initialise the detector {self.detector_name} in the WEB UI: {self._host}."
)
if self.jfj_client.detector_state != DetectorState.IDLE:
raise RuntimeError(
f"Detector {self.detector_name} is not in IDLE state, current state: {self.jfj_client.detector_state}. Please initialize the detector in the WEB UI: {self._host}."
)
# TODO - check again once Eiger should be initialized automatically, currently human initialization is expected
# # Once the automation should be enabled, we may use here
# detector_selection = [
# det for det in available_detectors.detectors if det.id == self.detector_name
# ]
# if not detector_selection:
# raise ValueError(
# f"Detector {self.detector_name} not found in available detectors: {[det.description for det in available_detectors.detectors]}"
# )
# det_id = detector_selection[0].id
# self.jfj_client.api.config_select_detector_put(
# detector_selection=DetectorSelection(id=det_id), _request_timeout=5
# )
# self.jfj_client.connect_and_initialise(timeout=10)
# Setup Detector settings, here we may also set the energy already as this might be time consuming
settings = DetectorSettings(frame_time_us=int(500), timing=DetectorTiming.TRIGGER)
self.jfj_client.set_detector_settings(settings, timeout=10)
# Set the file writer to the appropriate output for the HDF5 file
file_writer_settings = FileWriterSettings(overwrite=True, format=FileWriterFormat.NXMXVDS)
logger.debug(
f"Setting writer_settings: {yaml.dump(file_writer_settings.to_dict(), indent=4)}"
)
self.jfj_client.api.config_file_writer_put(
file_writer_settings=file_writer_settings, _request_timeout=10
)
# Start the preview client
self.jfj_preview_client.connect()
self.jfj_preview_client.start()
logger.info(f"Connected to JungfrauJoch preview stream at {self.jfj_preview_client.url}")
def on_stage(self) -> DeviceStatus | None:
"""
Called while staging the device.
Information about the upcoming scan can be accessed from the scan_info object.
"""
start_time = time.time()
scan_msg = self.scan_info.msg
# Set acquisition parameter
# TODO add check of mono energy, this can then also be passed to DatasetSettings
incident_energy = 12.0
exp_time = scan_msg.scan_parameters.get("exp_time", 0)
if exp_time <= self._readout_time:
raise ValueError(
f"Receive scan request for scan {scan_msg.scan_name} with exp_time {exp_time}s, which must be larger than the readout time {self._readout_time}s of the detector {self.detector_name}."
)
frame_time_us = exp_time #
ntrigger = int(scan_msg.num_points * scan_msg.scan_parameters["frames_per_trigger"])
# Fetch file path
self._full_path = get_full_path(scan_msg, name=f"{self.name}_master")
self._full_path = os.path.abspath(os.path.expanduser(self._full_path))
# Inform BEC about upcoming file event
self.file_event.put(
file_path=self._full_path,
done=False,
successful=False,
hinted_h5_entries={"data": "entry/data/data"},
)
# JFJ adds _master.h5 automatically
path = os.path.relpath(self._full_path, start="/sls/x12sa/data").removesuffix("_master.h5")
data_settings = DatasetSettings(
image_time_us=int(frame_time_us * 1e6), # This is currently ignored
ntrigger=ntrigger,
file_prefix=path,
beam_x_pxl=int(self._beam_center[0]),
beam_y_pxl=int(self._beam_center[1]),
detector_distance_mm=self.detector_distance,
incident_energy_ke_v=incident_energy,
)
logger.debug(f"Setting data_settings: {yaml.dump(data_settings.to_dict(), indent=4)}")
prep_time = start_time - time.time()
logger.debug(f"Prepared information for eiger to start acquisition in {prep_time:.2f}s")
self.jfj_client.wait_for_idle(timeout=10, request_timeout=10) # Ensure we are in IDLE state
self.jfj_client.start(settings=data_settings) # Takes around ~0.6s
logger.debug(f"Wait for IDLE and start call took {time.time()-start_time-prep_time:.2f}s")
def on_unstage(self) -> DeviceStatus:
"""Called while unstaging the device."""
def on_pre_scan(self) -> DeviceStatus:
"""Called right before the scan starts on all devices automatically."""
def on_trigger(self) -> DeviceStatus:
"""Called when the device is triggered."""
def _file_event_callback(self, status: DeviceStatus) -> None:
"""Callback to update the file_event signal when the acquisition is done."""
logger.info(f"Acquisition done callback called for {self.name} for status {status.success}")
self.file_event.put(
file_path=self._full_path,
done=status.done,
successful=status.success,
hinted_h5_entries={"data": "entry/data/data"},
)
def on_complete(self) -> DeviceStatus:
"""Called to inquire if a device has completed a scans."""
def wait_for_complete():
start_time = time.time()
timeout = 10
for _ in range(timeout):
if self.jfj_client.wait_for_idle(timeout=1, request_timeout=10):
return
statistics: MeasurementStatistics = self.jfj_client.api.statistics_data_collection_get(
_request_timeout=5
)
raise TimeoutError(
f"Timeout after waiting for detector {self.name} to complete for {time.time()-start_time:.2f}s, measurement statistics: {yaml.dump(statistics.to_dict(), indent=4)}"
)
status = self.task_handler.submit_task(wait_for_complete, run=True)
status.add_callback(self._file_event_callback)
self.cancel_on_stop(status)
return status
def on_kickoff(self) -> DeviceStatus | None:
"""Called to kickoff a device for a fly scan. Has to be called explicitly."""
def on_stop(self) -> None:
"""Called when the device is stopped."""
self.jfj_client.stop(
request_timeout=0.5
) # Call should not block more than 0.5 seconds to stop all devices...
self.task_handler.shutdown()

View File

@@ -0,0 +1,54 @@
"""
Eiger 1.5M specific integration. It is based on the Eiger base integration for the JungfrauJoch backend
which is placed in eiger_csaxs, and where code that is equivalent for the Eiger9M and Eiger1.5M is shared.
Please check the eiger_csaxs.py class for more details about the relevant services.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from csaxs_bec.devices.jungfraujoch.eiger import Eiger
EIGER1_5M_READOUT_TIME_US = 500e-6 # 500 microseconds in s
DETECTOR_NAME = "EIGER 1.5M"
if TYPE_CHECKING: # pragma no cover
from bec_lib.devicemanager import ScanInfo
from bec_server.device_server.device_server import DeviceManagerDS
# pylint:disable=invalid-name
class Eiger1_5M(Eiger):
"""
Eiger 1.5M specific integration for the in-vaccum Eiger.
The logic implemented here is coupled to the DelayGenerator integration,
repsonsible for the global triggering of all devices through a single Trigger logic.
Please check the eiger.py class for more details about the integration of relevant backend
services. The detector_name must be set to "EIGER 1.5M:
"""
USER_ACCESS = Eiger.USER_ACCESS + [] # Add more user_access methods here.
def __init__(
self,
name: str,
detector_distance: float = 100.0,
beam_center: tuple[float, float] = (0.0, 0.0),
scan_info: ScanInfo = None,
device_manager: DeviceManagerDS = None,
**kwargs,
) -> None:
super().__init__(
name=name,
detector_name=DETECTOR_NAME,
readout_time=EIGER1_5M_READOUT_TIME_US,
detector_distance=detector_distance,
beam_center=beam_center,
scan_info=scan_info,
device_manager=device_manager,
**kwargs,
)

View File

@@ -0,0 +1,58 @@
"""
Eiger 9M specific integration. It is based on the Eiger base integration for the JungfrauJoch backend
which is placed in eiger_csaxs, and where code that is equivalent for the Eiger9M and Eiger1.5M is shared.
Please check the eiger_csaxs.py class for more details about the relevant services.
In 16bit mode, 8e7 counts/s per pixel are supported in summed up frames,
although subframes will never have more than 12bit counts (~4000 counts per pixel in subframe).
In 32bit mode, 2e7 counts/s per pixel are supported, for which subframes will have no
more than 24bit counts, which means 16.7 million counts per pixel in subframes.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from csaxs_bec.devices.jungfraujoch.eiger import Eiger
if TYPE_CHECKING: # pragma no cover
from bec_lib.devicemanager import ScanInfo
from bec_server.device_server.device_server import DeviceManagerDS
EIGER9M_READOUT_TIME_US = 500e-6 # 500 microseconds in s
DETECTOR_NAME = "EIGER 8.5M (tmp)" # "EIGER 9M""
# pylint:disable=invalid-name
class Eiger9M(Eiger):
"""
Eiger 1.5M specific integration for the in-vaccum Eiger.
The logic implemented here is coupled to the DelayGenerator integration,
repsonsible for the global triggering of all devices through a single Trigger logic.
Please check the eiger.py class for more details about the integration of relevant backend
services. The detector_name must be set to "EIGER 1.5M:
"""
USER_ACCESS = Eiger.USER_ACCESS + [] # Add more user_access methods here.
def __init__(
self,
name: str,
detector_distance: float = 100.0,
beam_center: tuple[float, float] = (0.0, 0.0),
scan_info: ScanInfo = None,
device_manager: DeviceManagerDS = None,
**kwargs,
) -> None:
super().__init__(
name=name,
detector_name=DETECTOR_NAME,
readout_time=EIGER9M_READOUT_TIME_US,
detector_distance=detector_distance,
beam_center=beam_center,
scan_info=scan_info,
device_manager=device_manager,
**kwargs,
)

View File

@@ -1,20 +1,35 @@
import enum
import math
"""Module with client interface for the Jungfrau Joch detector API"""
import jfjoch_client
from __future__ import annotations
import enum
import time
import traceback
from typing import TYPE_CHECKING
import requests
from bec_lib.logger import bec_logger
from jfjoch_client.api.default_api import DefaultApi
from jfjoch_client.api_client import ApiClient
from jfjoch_client.configuration import Configuration
from jfjoch_client.models.broker_status import BrokerStatus
from jfjoch_client.models.dataset_settings import DatasetSettings
from jfjoch_client.models.detector_settings import DetectorSettings
logger = bec_logger.logger
if TYPE_CHECKING:
from ophyd import Device
# pylint: disable=raise-missing-from
# pylint: disable=broad-except
class JungfrauJochClientError(Exception):
"""Base class for exceptions in this module."""
class DetectorState(enum.StrEnum):
"""Detector states for Jungfrau Joch detector
['Inactive', 'Idle', 'Busy', 'Measuring', 'Pedestal', 'Error']
"""
class DetectorState(str, enum.Enum):
"""Possible Detector states for Jungfrau Joch detector"""
INACTIVE = "Inactive"
IDLE = "Idle"
@@ -24,24 +39,30 @@ class DetectorState(enum.StrEnum):
ERROR = "Error"
class ResponseWaitDone(enum.IntEnum):
"""Response state for Jungfrau Joch detector wait till done"""
DETECTOR_IDLE = 200
TIMEOUT_PARAM_OUT_OF_RANGE = 400
JUNGFRAU_ERROR = 500
DETECTOR_INACTIVE = 502
TIMEOUT_REACHED = 504
class JungfrauJochClient:
"""Thin wrapper around the Jungfrau Joch API client"""
"""Thin wrapper around the Jungfrau Joch API client.
def __init__(self, host: str = "http://sls-jfjoch-001:8080") -> None:
sudo systemctl restart jfjoch_broker
sudo systemctl status jfjoch_broker
It looks as if the detector is not being stopped properly.
One module remains running, how can we restart the detector?
"""
def __init__(
self, host: str = "http://sls-jfjoch-001:8080", parent: Device | None = None
) -> None:
self._initialised = False
configuration = jfjoch_client.Configuration(host=host)
api_client = jfjoch_client.ApiClient(configuration)
self.api = jfjoch_client.DefaultApi(api_client)
configuration = Configuration(host=host)
api_client = ApiClient(configuration)
self.api = DefaultApi(api_client)
self._parent_name = parent.name if parent else self.__class__.__name__
@property
def jjf_state(self) -> BrokerStatus:
"""Get the status of JungfrauJoch"""
response = self.api.status_get()
return BrokerStatus(**response.to_dict())
@property
def initialised(self) -> bool:
@@ -53,101 +74,113 @@ class JungfrauJochClient:
"""Set the connected status"""
self._initialised = value
def get_jungfrau_joch_status(self) -> DetectorState:
# TODO this is not correct, as it may be that the state in INACTIVE. Models are not in sync...
# REMOVE all model enums as most of the validation takes place in the Pydantic models, i.e. BrokerStatus here..
@property
def detector_state(self) -> DetectorState:
"""Get the status of JungfrauJoch"""
return self.api.status_get().state
return DetectorState(self.jjf_state.state)
def connect_and_initialise(self, timeout: int = 5) -> None:
def connect_and_initialise(self, timeout: int = 10, **kwargs) -> None:
"""Check if JungfrauJoch is connected and ready to receive commands"""
status = self.api.status_get().state
status = self.detector_state
if status != DetectorState.IDLE:
self.api.initialize_post()
self.wait_till_done(timeout)
self.initialised = True
self.api.initialize_post() # This is a blocking call....
self.wait_for_idle(timeout, request_timeout=timeout) # Blocking call
self.initialised = True
def set_detector_settings(self, settings: dict | jfjoch_client.DatasetSettings) -> None:
def set_detector_settings(self, settings: dict | DetectorSettings, timeout: int = 10) -> None:
"""Set the detector settings. JungfrauJoch must be in IDLE, Error or Inactive state.
Note, the full settings have to be provided, otherwise the settings will be overwritten with default values.
Args:
settings (dict): dictionary of settings
"""
state = self.api.status_get().state
state = self.detector_state
if state not in [DetectorState.IDLE, DetectorState.ERROR, DetectorState.INACTIVE]:
raise JungfrauJochClientError(
f"Detector must be in IDLE, ERROR or INACTIVE state to set settings. Current state: {state}"
)
time.sleep(1) # Give the detector 1s to become IDLE, retry
state = self.detector_state
if state not in [DetectorState.IDLE, DetectorState.ERROR, DetectorState.INACTIVE]:
raise JungfrauJochClientError(
f"Error in {self._parent_name}. Detector must be in IDLE, ERROR or INACTIVE state to set settings. Current state: {state}"
)
if isinstance(settings, dict):
settings = jfjoch_client.DatasetSettings(**settings)
self.api.config_detector_put(settings)
settings = DetectorSettings(**settings)
try:
self.api.config_detector_put(detector_settings=settings, _request_timeout=timeout)
except requests.exceptions.Timeout:
raise TimeoutError(f"Timeout while setting detector settings for {self._parent_name}")
except Exception:
content = traceback.format_exc()
raise JungfrauJochClientError(
f"Error while setting detector settings for {self._parent_name}: {content}"
)
def set_mesaurement_settings(self, settings: dict | jfjoch_client.DatasetSettings) -> None:
"""Set the measurement settings. JungfrauJoch must be in IDLE state.
def start(self, settings: dict | DatasetSettings, request_timeout: float = 10) -> None:
"""Start the mesaurement. DatasetSettings must be provided, and JungfrauJoch must be in IDLE state.
The method call is blocking and JungfrauJoch will be ready to measure after the call resolves.
Please check the DataSettings class for the available settings.
The minimum required settings are:
beam_x_pxl: StrictFloat | StrictInt,
beam_y_pxl: StrictFloat | StrictInt,
detector_distance_mm: float | int,
incident_energy_keV: float | int,
Args:
settings (dict): dictionary of settings
Please check the DataSettings class for the available settings. Minimum required settings are
beam_x_pxl, beam_y_pxl, detector_distance_mm, incident_energy_keV.
"""
state = self.api.status_get().state
state = self.detector_state
if state != DetectorState.IDLE:
raise JungfrauJochClientError(
f"Detector must be in IDLE state to set settings. Current state: {state}"
f"Error in {self._parent_name}. Detector must be in IDLE state to set settings. Current state: {state}"
)
if isinstance(settings, dict):
settings = jfjoch_client.DatasetSettings(**settings)
settings = DatasetSettings(**settings)
try:
res = self.api.start_post_with_http_info(dataset_settings=settings)
if res.status_code != 200:
logger.error(
f"Error while setting measurement settings {settings}, response: {res}"
)
raise JungfrauJochClientError(
f"Error while setting measurement settings {settings}, response: {res}"
)
except Exception as e:
logger.error(
f"Error while setting measurement settings {settings}. Exception raised {e}"
self.api.start_post_with_http_info(
dataset_settings=settings, _request_timeout=request_timeout
)
except requests.exceptions.Timeout:
raise TimeoutError(
f"TimeoutError in JungfrauJochClient for parent device {self._parent_name} for 'start' call"
)
except Exception:
content = traceback.format_exc()
raise JungfrauJochClientError(
f"Error while setting measurement settings {settings}. Exception raised {e}"
) from e
f"Error in JungfrauJochClient for parent device {self._parent_name} during 'start' call: {content}"
)
def wait_till_done(self, timeout: int = 5) -> None:
"""Wait until JungfrauJoch is done.
def stop(self, request_timeout: float = 0.5) -> None:
"""Stop the acquisition, this only logs errors and is not raising."""
try:
self.api.cancel_post_with_http_info(_request_timeout=request_timeout)
except requests.exceptions.Timeout:
content = traceback.format_exc()
logger.error(
f"Timeout in JungFrauJochClient for device {self._parent_name} during stop: {content}"
)
except Exception:
content = traceback.format_exc()
logger.error(
f"Error in JungFrauJochClient for device {self._parent_name} during stop: {content}"
)
def wait_for_idle(self, timeout: int = 10, request_timeout: float | None = None) -> bool:
"""Wait for JungfrauJoch to be in Idle state. Blocking call with timeout.
Args:
timeout (int): timeout in seconds
Returns:
bool: True if the detector is in IDLE state, False if timeout occurred
"""
success = False
if request_timeout is None:
request_timeout = timeout
try:
response = self.api.wait_till_done_post_with_http_info(math.ceil(timeout / 2))
if response.status_code != ResponseWaitDone.DETECTOR_IDLE:
logger.info(
f"Waitin for JungfrauJoch to be done, status: {ResponseWaitDone(response.status_code)}; response msg {response}"
)
response = self.api.wait_till_done_post_with_http_info(math.floor(timeout / 2))
if response.status_code == ResponseWaitDone.DETECTOR_IDLE:
success = True
return
except Exception as e:
logger.error(f"Error while waiting for JungfrauJoch to initialise: {e}")
raise JungfrauJochClientError(
f"Error while waiting for JungfrauJoch to initialise: {e}"
) from e
else:
if success is False:
logger.error(
f"Failed to initialise JungfrauJoch with status: {response.status_code}; response msg {response}"
)
raise JungfrauJochClientError(
f"Failed to initialise JungfrauJoch with status: {response.status_code}; response msg {response}"
)
self.api.wait_till_done_post(timeout=timeout, _request_timeout=request_timeout)
except requests.exceptions.Timeout:
raise TimeoutError(f"HTTP request timeout in wait_for_idle for {self._parent_name}")
except Exception:
content = traceback.format_exc()
logger.debug(f"Waiting for device {self._parent_name} to become IDLE: {content}")
return False
return True

View File

@@ -0,0 +1,96 @@
"""Module for the Eiger preview ZMQ stream."""
from __future__ import annotations
import json
import threading
import time
from typing import Callable
import numpy as np
import zmq
from bec_lib.logger import bec_logger
logger = bec_logger.logger
ZMQ_TOPIC_FILTER = b""
class JungfrauJochPreview:
USER_ACCESS = ["start", "stop"]
def __init__(self, url: str, cb: Callable):
self.url = url
self._socket = None
self._shutdown_event = threading.Event()
self._zmq_thread = None
self._on_update_callback = cb
def connect(self):
"""Connect to the JungfrauJoch PUB-SUB streaming interface
JungfrauJoch 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
context = zmq.Context()
self._socket = context.socket(zmq.SUB)
self._socket.setsockopt(zmq.SUBSCRIBE, ZMQ_TOPIC_FILTER)
try:
self._socket.connect(self.url)
except ConnectionRefusedError:
time.sleep(1)
self._socket.connect(self.url)
def start(self):
self._zmq_thread = threading.Thread(
target=self._zmq_update_loop, daemon=True, name="JungfrauJoch_live_preview"
)
self._zmq_thread.start()
def stop(self):
self._shutdown_event.set()
if self._zmq_thread:
self._zmq_thread.join()
def _zmq_update_loop(self):
while not self._shutdown_event.is_set():
if self._socket is None:
self.connect()
try:
self._poll()
except ValueError:
# Happens when ZMQ partially delivers the multipart message
pass
except zmq.error.Again:
# Happens when receive queue is empty
time.sleep(0.1)
def _poll(self):
"""
Poll the ZMQ socket for new data. It will throttle the data update and
only subscribe to the topic for a single update. This is not very nice
but it seems like there is currently no option to set the update rate on
the backend.
"""
if self._shutdown_event.wait(0.2):
return
try:
# subscribe to the topic
self._socket.setsockopt(zmq.SUBSCRIBE, ZMQ_TOPIC_FILTER)
# pylint: disable=no-member
r = self._socket.recv_multipart(flags=zmq.NOBLOCK)
self._parse_data(r)
finally:
# Unsubscribe from the topic
self._socket.setsockopt(zmq.UNSUBSCRIBE, ZMQ_TOPIC_FILTER)
def _parse_data(self, data):
# TODO decode and parse the data
# self._on_update_callback(data)
pass

View File

@@ -412,10 +412,11 @@ class NPointAxis(Device, PositionerBase):
sign=1,
socket_cls=SocketIO,
tolerance: float = 0.05,
device_manager=None,
**kwargs,
):
self.controller = NPointController(
socket_cls=socket_cls, socket_host=host, socket_port=port
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
)
self.axis_Id = axis_Id
self.sign = sign
@@ -441,6 +442,9 @@ class NPointAxis(Device, PositionerBase):
self.low_limit_travel.put(limits[0])
self.high_limit_travel.put(limits[1])
def wait_for_connection(self, all_signals=False, timeout: float = 30.0) -> bool:
self.controller.on(timeout=timeout)
@property
def limits(self):
return (self.low_limit_travel.get(), self.high_limit_travel.get())

View File

@@ -24,24 +24,25 @@ class FlomniSampleStorage(Device):
SUB_VALUE = "value"
_default_sub = SUB_VALUE
sample_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_flomni{i}:GET", {}) for i in range(21)
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_flomni{i}:GET", {"auto_monitor": True}) for i in range(21)
}
sample_placed = Dcpt(sample_placed)
sample_names = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_flomni{i}:GET.DESC", {"string": True})
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_flomni{i}:GET.DESC", {"string": True, "auto_monitor": True})
for i in range(21)
}
sample_names = Dcpt(sample_names)
sample_in_gripper = Cpt(
EpicsSignal, name="sample_in_gripper", read_pv="XOMNY-SAMPLE_DB_flomni100:GET"
EpicsSignal, name="sample_in_gripper", read_pv="XOMNY-SAMPLE_DB_flomni100:GET", auto_monitor=True
)
sample_in_gripper_name = Cpt(
EpicsSignal,
name="sample_in_gripper_name",
read_pv="XOMNY-SAMPLE_DB_flomni100:GET.DESC",
string=True,
auto_monitor=True
)
def __init__(self, prefix="", *, name, **kwargs):

View File

@@ -0,0 +1,208 @@
import time
import datetime
from ophyd import Component as Cpt
from ophyd import Device
from ophyd import DynamicDeviceComponent as Dcpt
from ophyd import EpicsSignal
from prettytable import FRAME, PrettyTable
import numpy as np
class FlomniTempHumError(Exception):
pass
class FlomniTempHum(Device):
USER_ACCESS = [
"show_all",
"help",
]
SUB_VALUE = "value"
_default_sub = SUB_VALUE
temperature_mirror = Cpt(
EpicsSignal, name="temperature_mirror", read_pv="XOMNI-TEMPHUM-MIRROR:0.VAL"
)
temperature_mirrorset_set = Cpt(
EpicsSignal, name="temperature_mirrorset_set", read_pv="XOMNI-TEMPHUM-MIRRORSET_SET:0.VAL"
)
temperature_mirrorset_rb = Cpt(
EpicsSignal, name="temperature_mirrorset_rb", read_pv="XOMNI-TEMPHUM-MIRRORSET_RB:0.VAL"
)
temperature_osa = Cpt(
EpicsSignal, name="temperature_osa", read_pv="XOMNI-TEMPHUM-OSA:0.VAL"
)
temperature_osaset_set = Cpt(
EpicsSignal, name="temperature_osaset_set", read_pv="XOMNI-TEMPHUM-OSASET_SET:0.VAL"
)
temperature_osaset_rb = Cpt(
EpicsSignal, name="temperature_osaset_rb", read_pv="XOMNI-TEMPHUM-OSASET_RB:0.VAL"
)
omegactrl_alive = Cpt(
EpicsSignal, name="omegactrl_alive", read_pv="XOMNI-TEMPHUM-OMEGACTRL-ALIVE:0.VAL"
)
galilctrl_alive = Cpt(
EpicsSignal, name="galilctrl_alive", read_pv="XOMNI-TEMPHUM-GALILCTRL-ALIVE:0.VAL"
)
temperature_heater = Cpt(
EpicsSignal, name="temperature_heater", read_pv="XOMNI-TEMPHUM-HEATER:0.VAL"
)
temperature_heaterset_set = Cpt(
EpicsSignal, name="temperature_heaterset_set", read_pv="XOMNI-TEMPHUM-HEATERSET_SET:0.VAL"
)
temperature_heaterset_rb = Cpt(
EpicsSignal, name="temperature_heaterset_rb", read_pv="XOMNI-TEMPHUM-HEATERSET_RB:0.VAL"
)
temperature_heaterhousing = Cpt(
EpicsSignal, name="temperature_heaterhousing", read_pv="XOMNI-TEMPHUM-HEATERHOUSE:0.VAL"
)
temperature_heaterhousing_alarm = Cpt(
EpicsSignal, name="temperature_heaterhousing_alarm", read_pv="XOMNI-TEMPHUM-HEATERHOUSEALARM:0.VAL"
)
temperature_heater_enabled = Cpt(
EpicsSignal, name="temperature_heater_enabled", read_pv="XOMNI-TEMPHUM-HEAT_EN:0.VAL"
)
temperature_heater_enabled = Cpt(
EpicsSignal, name="temperature_heater_enabled", read_pv="XOMNI-TEMPHUM-HEAT_EN:0.VAL"
)
###### GALIL CONTROLLER
humidity_sensor1 = Cpt(
EpicsSignal, name="humidity_sensor1", read_pv="XOMNI-TEMPHUM-HUM1:0.VAL"
)
humidity_sensor2 = Cpt(
EpicsSignal, name="humidity_sensor2", read_pv="XOMNI-TEMPHUM-HUM2:0.VAL"
)
humidity_sensor1_temperature = Cpt(
EpicsSignal, name="humidity_sensor1_temperature", read_pv="XOMNI-TEMPHUM-TEMP1:0.VAL"
)
humidity_sensor2_temperature = Cpt(
EpicsSignal, name="humidity_sensor2_temperature", read_pv="XOMNI-TEMPHUM-TEMP2:0.VAL"
)
humidity_sensor1_err = Cpt(
EpicsSignal, name="humidity_sensor1_err", read_pv="XOMNI-TEMPHUM-ERR1:0.VAL"
)
humidity_sensor2_err = Cpt(
EpicsSignal, name="humidity_sensor2_err", read_pv="XOMNI-TEMPHUM-ERR2:0.VAL"
)
flow = Cpt(
EpicsSignal, name="flow", read_pv="XOMNI-TEMPHUM-FLOW:0.VAL"
)
flowset = Cpt(
EpicsSignal, name="flowset", read_pv="XOMNI-TEMPHUM-FLOWSET:0.VAL"
)
flowset_set = Cpt(
EpicsSignal, name="flowset_set", read_pv="XOMNI-TEMPHUM-FLOWSETSET:0.VAL"
)
humidityset = Cpt(
EpicsSignal, name="humidityset", read_pv="XOMNI-TEMPHUM-HUMSET:0.VAL"
)
humidityset_set = Cpt(
EpicsSignal, name="humidityset_set", read_pv="XOMNI-TEMPHUM-HUMSETSET:0.VAL"
)
suction = Cpt(
EpicsSignal, name="suction", read_pv="XOMNI-TEMPHUM-SUCTION:0.VAL"
)
valvedry = Cpt(
EpicsSignal, name="valvedry", read_pv="XOMNI-TEMPHUM-VALVEDRY:0.VAL"
)
valvewet = Cpt(
EpicsSignal, name="valvewet", read_pv="XOMNI-TEMPHUM-VALVEWET:0.VAL"
)
setuptemp = Cpt(
EpicsSignal, name="setuptemp", read_pv="XOMNI-TEMPHUM-SETUPTEMP:0.VAL"
)
def omega_controller_running(self):
time_diff = np.fabs(float(self.omegactrl_alive.get()) - time.time())
if time_diff > 120:
return False
else:
return True
def galil_controller_running(self):
time_diff = np.fabs(float(self.galilctrl_alive.get()) - time.time())
if time_diff > 120:
return False
else:
return True
def __init__(self, prefix="", *, name, **kwargs):
super().__init__(prefix, name=name, **kwargs)
self.temperature_mirror.subscribe(self._emit_value, run=False)
def _emit_value(self, **kwargs):
timestamp = kwargs.pop("timestamp", time.time())
self._run_subs(sub_type=self.SUB_VALUE, timestamp=timestamp, obj=self)
def show_all(self):
print("=== flOMNI Temperature & Humidity Overview ===")
print("")
print("Temperatures:")
print(f" Mirror: {float(self.temperature_mirror.get()):7.2f} °C")
print(f" Mirror Setpoint (RB): {float(self.temperature_mirrorset_rb.get()):7.2f} °C")
print(f" OSA: {float(self.temperature_osa.get()):7.2f} °C")
print(f" OSA Setpoint (RB): {float(self.temperature_osaset_rb.get()):7.2f} °C")
print(f" Heater: {float(self.temperature_heater.get()):7.2f} °C")
print(f" Heater Setpoint (RB): {float(self.temperature_heaterset_rb.get()):7.2f} °C")
print(f" Heater Enabled: {float(self.temperature_heater_enabled.get()):.0f}")
print(f" Heater Housing: {float(self.temperature_heaterhousing.get()):7.2f} °C")
print(f" Heater Housing Alarm: {float(self.temperature_heaterhousing_alarm.get()):.0f}")
print("")
print("Humidity Sensors:")
print(f" Sensor 1 Humidity: {float(self.humidity_sensor1.get()):7.2f} %RH")
print(f" Sensor 1 Temperature: {float(self.humidity_sensor1_temperature.get()):7.2f} °C")
print(f" Sensor 1 Error: {float(self.humidity_sensor1_err.get()):.0f}")
print(f" Sensor 2 Humidity: {float(self.humidity_sensor2.get()):7.2f} %RH")
print(f" Sensor 2 Temperature: {float(self.humidity_sensor2_temperature.get()):7.2f} °C")
print(f" Sensor 2 Error: {float(self.humidity_sensor2_err.get()):.0f}")
print(f" Humidity Setpoint: {float(self.humidityset.get()):7.2f} %RH")
print("")
print("Flow Control:")
print(f" Flow: {float(self.flow.get()):7.2f} sccm")
print(f" Flow Setpoint (RB): {float(self.flowset.get()):7.2f} sccm")
print("")
print("Suction:")
print(f" Suction: {float(self.suction.get()):7.2f}")
print("")
print("Valves:")
print(f" Dry Valve: {float(self.valvedry.get()):.0f}")
print(f" Wet Valve: {float(self.valvewet.get()):.0f}")
print("")
print("Controller Heartbeats:")
print(f" OMEGA Controller Alive: {self.omega_controller_running()}")
print(f" GALIL Controller Alive: {self.galil_controller_running()}")
print("==============================================")
def help(self):
print("Help for flOMNI temperature and humidity control system:")
print("Available methods:")
print(" show_all() - display all current values")

View File

@@ -175,7 +175,7 @@ class FlomniGalilMotor(Device, PositionerBase):
**kwargs,
):
self.controller = FlomniGalilController(
socket_cls=socket_cls, socket_host=host, socket_port=port
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
)
self.axis_Id = axis_Id
self.controller.set_axis(axis=self, axis_nr=self.axis_Id_numeric)
@@ -212,6 +212,9 @@ class FlomniGalilMotor(Device, PositionerBase):
self.low_limit_travel.put(limits[0])
self.high_limit_travel.put(limits[1])
def wait_for_connection(self, all_signals=False, timeout: float = 30.0) -> bool:
self.controller.on(timeout=timeout)
@property
def limits(self):
return (self.low_limit_travel.get(), self.high_limit_travel.get())
@@ -342,10 +345,10 @@ class FlomniGalilMotor(Device, PositionerBase):
Drive an axis to the limit in a specified direction.
Args:
direction (str): Direction in which the axis should be driven to the limit. Either 'forward' or 'reverse'.
direction (str): Direction in which the axis should be driven to the limit. Either 'forward' or 'reverse'.
"""
self.controller.drive_axis_to_limit(self.axis_Id_numeric, direction)
#now force position read to cache
# now force position read to cache
val = self.readback.read()
self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time())

View File

@@ -149,7 +149,7 @@ class FuprGalilMotor(Device, PositionerBase):
**kwargs,
):
self.controller = FuprGalilController(
socket_cls=socket_cls, socket_host=host, socket_port=port
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
)
self.axis_Id = axis_Id
self.controller.set_axis(axis=self, axis_nr=self.axis_Id_numeric)
@@ -185,6 +185,9 @@ class FuprGalilMotor(Device, PositionerBase):
self.low_limit_travel.put(limits[0])
self.high_limit_travel.put(limits[1])
def wait_for_connection(self, all_signals=False, timeout: float = 30.0) -> bool:
self.controller.on(timeout=timeout)
@property
def limits(self):
return (self.low_limit_travel.get(), self.high_limit_travel.get())

View File

@@ -59,12 +59,12 @@ class GalilController(Controller):
"all_axes_referenced",
]
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
OKBLUE = "\033[94m"
OKCYAN = "\033[96m"
OKGREEN = "\033[92m"
WARNING = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
@threadlocked
def socket_put(self, val: str) -> None:
@@ -115,29 +115,29 @@ class GalilController(Controller):
def axis_is_referenced(self, axis_Id_numeric) -> bool:
return bool(float(self.socket_put_and_receive(f"MG axisref[{axis_Id_numeric}]").strip()))
def folerr_status(self, axis_Id_numeric) -> bool:
return bool(float(self.socket_put_and_receive(f"MG folaxerr[{axis_Id_numeric}]").strip()))
def motor_temperature(self, axis_Id_numeric) -> float:
#this is only valid for omny. consider moving to ogalil
# this is only valid for omny. consider moving to ogalil
voltage = float(self.socket_put_and_receive(f"MG @AN[{axis_Id_numeric+1}]").strip())
voltage2 = float(self.socket_put_and_receive(f"MG @AN[{axis_Id_numeric+1}]").strip())
if voltage2 < voltage:
voltage = voltage2
# convert from [-10,10]V to [0,300]degC
temperature_degC = round((voltage+10.0) / 20.0 * 300.0, 1)
temperature_degC = round((voltage + 10.0) / 20.0 * 300.0, 1)
#the motors of the parking station have a different offset
#the range is reduced, so if at the limit, we show an extreme value
# the motors of the parking station have a different offset
# the range is reduced, so if at the limit, we show an extreme value
if self.sock.port == 8082:
#controller 2
# controller 2
if axis_Id_numeric == 6:
temperature_degC = round((voltage+10.0-11.4) / 20.0 * 300.0, 1)
temperature_degC = round((voltage + 10.0 - 11.4) / 20.0 * 300.0, 1)
if voltage > 9.9:
temperature_degC = 300
if axis_Id_numeric == 7:
temperature_degC = round((voltage+.0-12) / 20.0 * 300.0, 1)
temperature_degC = round((voltage + 0.0 - 12) / 20.0 * 300.0, 1)
if voltage > 9.9:
temperature_degC = 300
return temperature_degC
@@ -147,16 +147,15 @@ class GalilController(Controller):
Check if all axes are referenced.
"""
return bool(float(self.socket_put_and_receive("MG allaxref").strip()))
def _omny_get_microstep_position(self,axis_Id):
def _omny_get_microstep_position(self, axis_Id):
return float(self.socket_put_and_receive(f"MG _TD{axis_Id}").strip())
def _omny_get_reference_limit(self,axis_Id):
def _omny_get_reference_limit(self, axis_Id):
get_axis_no = float(self.socket_put_and_receive(f"MG frmmv").strip())
if(get_axis_no>0):
if get_axis_no > 0:
reference_is_before = float(self.socket_put_and_receive(f"MG _FL{axis_Id}").strip())
elif(get_axis_no<0):
elif get_axis_no < 0:
reference_is_before = float(self.socket_put_and_receive(f"MG _BL{axis_Id}").strip())
else:
reference_is_before = 0
@@ -187,7 +186,11 @@ class GalilController(Controller):
while self.is_axis_moving(None, axis_Id_numeric):
time.sleep(0.01)
if verbose:
self.get_device_manager().connector.send_client_info(f"Current microstep position {self._omny_get_microstep_position(axis_Id):.0f}", scope="drive axis to limit", show_asap=True)
self.device_manager.connector.send_client_info(
f"Current microstep position {self._omny_get_microstep_position(axis_Id):.0f}",
scope="drive axis to limit",
show_asap=True,
)
time.sleep(0.5)
# check if we actually hit the limit
@@ -201,13 +204,7 @@ class GalilController(Controller):
else:
print("Limit reached.")
def get_device_manager(self):
for axis in self._axis:
if hasattr(axis, "device_manager") and axis.device_manager:
return axis.device_manager
raise BECConfigError("Could not access the device_manager")
def find_reference(self, axis_Id_numeric: int, verbose=0, raise_error = 1) -> None:
def find_reference(self, axis_Id_numeric: int, verbose=0, raise_error=1) -> None:
"""
Find the reference of an axis.
@@ -224,7 +221,11 @@ class GalilController(Controller):
while self.is_axis_moving(None, axis_Id_numeric):
time.sleep(0.1)
if verbose:
self.get_device_manager().connector.send_client_info(f"Current microstep position {self._omny_get_microstep_position(axis_Id):.0f} reference is before {self._omny_get_reference_limit(axis_Id)}", scope="find axis reference", show_asap=True)
self.device_manager.connector.send_client_info(
f"Current microstep position {self._omny_get_microstep_position(axis_Id):.0f} reference is before {self._omny_get_reference_limit(axis_Id)}",
scope="find axis reference",
show_asap=True,
)
time.sleep(0.5)
if not self.axis_is_referenced(axis_Id_numeric):
@@ -236,7 +237,6 @@ class GalilController(Controller):
logger.info(f"Successfully found reference of axis {axis_Id_numeric}.")
print(f"Successfully found reference of axis {axis_Id_numeric}.")
def show_running_threads(self) -> None:
t = PrettyTable()
t.title = f"Threads on {self.sock.host}:{self.sock.port}"
@@ -251,7 +251,7 @@ class GalilController(Controller):
def is_motor_on(self, axis_Id) -> bool:
return not bool(float(self.socket_put_and_receive(f"MG _MO{axis_Id}").strip()))
def get_motor_limit_switch(self, axis_Id) -> list:
"""
Get the status of the motor limit switches.
@@ -269,14 +269,7 @@ class GalilController(Controller):
def describe(self) -> None:
t = PrettyTable()
t.title = f"{self.__class__.__name__} on {self.sock.host}:{self.sock.port}"
field_names = [
"Axis",
"Name",
"Referenced",
"Motor On",
"Limits",
"Position",
]
field_names = ["Axis", "Name", "Referenced", "Motor On", "Limits", "Position"]
# in case of OMNY
if self.sock.host == "mpc3217.psi.ch":
field_names.append("Temperature")
@@ -286,7 +279,7 @@ class GalilController(Controller):
axis = self._axis[ax]
if axis is not None:
if self.sock.host == "mpc3217.psi.ch":
#case of omny. possibly consider moving to ogalil
# case of omny. possibly consider moving to ogalil
motor_on = self.is_motor_on(axis.axis_Id)
if motor_on == True:
motor_on = self.WARNING + "ON" + self.ENDC
@@ -299,7 +292,7 @@ class GalilController(Controller):
else:
folerr_status = "False"
position = axis.readback.read().get(axis.name).get("value")
position = f'{position:.3f}'
position = f"{position:.3f}"
t.add_row(
[
f"{axis.axis_Id_numeric}/{axis.axis_Id}",
@@ -330,8 +323,6 @@ class GalilController(Controller):
self.show_running_threads()
self.show_status_other()
def show_status_other(self) -> None:
"""
Show additional device-specific status information.
@@ -420,7 +411,7 @@ class GalilSetpointSignal(GalilSignalBase):
while self.controller.is_thread_active(0):
time.sleep(0.1)
#in the case of lamni, consider moving to lgalil
# in the case of lamni, consider moving to lgalil
if self.parent.axis_Id_numeric == 2 and self.controller.sock.host == "mpc2680.psi.ch":
try:
rt = self.parent.device_manager.devices[self.parent.rt]

View File

@@ -0,0 +1,35 @@
from ophyd_devices.utils.controller import Controller, threadlocked
from ophyd_devices.utils.socket import SocketSignal
from csaxs_bec.devices.omny.galil.galil_ophyd import GalilCommunicationError, retry_once
class GalilRIO(Controller):
@threadlocked
def socket_put(self, val: str) -> None:
self.sock.put(f"{val}\r".encode())
@retry_once
def socket_put_confirmed(self, val: str) -> None:
"""Send message to controller and ensure that it is received by checking that the socket receives a colon.
Args:
val (str): Message that should be sent to the socket
Raises:
GalilCommunicationError: Raised if the return value is not a colon.
"""
return_val = self.socket_put_and_receive(val)
if return_val != ":":
raise GalilCommunicationError(
f"Expected return value of ':' but instead received {return_val}"
)
class GalilRIOSignalBase(SocketSignal):
def __init__(self, signal_name, **kwargs):
self.signal_name = signal_name
super().__init__(**kwargs)
self.rio_controller = self.parent.rio_controller

View File

@@ -73,6 +73,7 @@ class LamniGalilController(GalilController):
air_off = bool(self.socket_put_and_receive("MG@OUT[13]"))
return rt_not_blocked_by_galil and air_off
class LamniGalilReadbackSignal(GalilSignalRO):
@retry_once
@threadlocked
@@ -99,6 +100,7 @@ class LamniGalilReadbackSignal(GalilSignalRO):
logger.warning("Failed to set RT value during readback.")
return val
class LamniGalilMotor(Device, PositionerBase):
USER_ACCESS = ["controller", "drive_axis_to_limit", "find_reference"]
readback = Cpt(LamniGalilReadbackSignal, signal_name="readback", kind="hinted")
@@ -132,7 +134,7 @@ class LamniGalilMotor(Device, PositionerBase):
**kwargs,
):
self.controller = LamniGalilController(
socket_cls=socket_cls, socket_host=host, socket_port=port
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
)
self.axis_Id = axis_Id
self.controller.set_axis(axis=self, axis_nr=self.axis_Id_numeric)
@@ -168,6 +170,9 @@ class LamniGalilMotor(Device, PositionerBase):
self.low_limit_travel.put(limits[0])
self.high_limit_travel.put(limits[1])
def wait_for_connection(self, all_signals=False, timeout: float = 30.0) -> bool:
self.controller.on(timeout=timeout)
@property
def limits(self):
return (self.low_limit_travel.get(), self.high_limit_travel.get())
@@ -292,7 +297,7 @@ class LamniGalilMotor(Device, PositionerBase):
Find the reference of the axis.
"""
self.controller.find_reference(self.axis_Id_numeric)
#now force position read to cache
# now force position read to cache
val = self.readback.read()
self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time())
@@ -301,10 +306,10 @@ class LamniGalilMotor(Device, PositionerBase):
Drive an axis to the limit in a specified direction.
Args:
direction (str): Direction in which the axis should be driven to the limit. Either 'forward' or 'reverse'.
direction (str): Direction in which the axis should be driven to the limit. Either 'forward' or 'reverse'.
"""
self.controller.drive_axis_to_limit(self.axis_Id_numeric, direction)
#now force position read to cache
# now force position read to cache
val = self.readback.read()
self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time())

View File

@@ -46,7 +46,7 @@ class GalilMotorResolution(GalilSignalRO):
@threadlocked
def _socket_get(self):
if self.controller.sock.port == 8083 and self.parent.axis_Id_numeric == 2:
# rotation stage
# rotation stage
return 89565.8666667
else:
return 51200
@@ -69,37 +69,43 @@ class OMNYGalilReadbackSignal(GalilSignalRO):
current_pos = float(self.controller.socket_put_and_receive(f"TP{self.parent.axis_Id}"))
current_pos *= self.parent.sign
step_mm = self.parent.motor_resolution.get()
#here we introduce an offset of 25 to the rotation axis
#when setting a position this is taken into account in the controller
#that way we just do tomography from 0 to 180 degrees
# here we introduce an offset of 25 to the rotation axis
# when setting a position this is taken into account in the controller
# that way we just do tomography from 0 to 180 degrees
if self.parent.axis_Id_numeric == 2 and self.controller.sock.port == 8083:
return (current_pos / step_mm)+25
return (current_pos / step_mm) + 25
else:
return current_pos / step_mm
def read(self):
self._metadata["timestamp"] = time.time()
val = super().read()
#if reading rotation stage angle
# if reading rotation stage angle
if self.parent.axis_Id_numeric == 2 and self.controller.sock.port == 8083:
current_readback_value = val[self.parent.name]["value"]
#print (f"previous rotation angle {self.previous_rotation_angle}, current readback {current_readback_value}.")
# print (f"previous rotation angle {self.previous_rotation_angle}, current readback {current_readback_value}.")
if np.fabs((self.previous_rotation_angle-current_readback_value)>10):
if np.fabs((self.previous_rotation_angle - current_readback_value) > 10):
message = f"Glitch detected in rotation stage. Previous rotation angle {self.previous_rotation_angle}, current readback {current_readback_value}."
print(message)
self.parent.device_manager.connector.send_client_info(message, scope="glitch detector", show_asap=True)
self.parent.device_manager.connector.send_client_info(
message, scope="glitch detector", show_asap=True
)
val = super().read()
current_readback_value = val[self.parent.name]["value"]
if np.fabs((self.previous_rotation_angle-current_readback_value)>10):
if np.fabs((self.previous_rotation_angle - current_readback_value) > 10):
message = f"Glitch detected in rotation stage second read. Previous rotation angle {self.previous_rotation_angle}, current readback {current_readback_value}. Disabling the controller."
print(message)
self.parent.device_manager.connector.send_client_info(message, scope="glitch detector", show_asap=True)
self.parent.device_manager.devices["osamroy"].obj.controller.socket_put_confirmed("allaxref=0")
self.parent.device_manager.connector.send_client_info(
message, scope="glitch detector", show_asap=True
)
self.parent.device_manager.devices["osamroy"].obj.controller.socket_put_confirmed(
"allaxref=0"
)
self.parent.device_manager.devices["osamroy"].obj.enabled = False
return val
@@ -108,13 +114,12 @@ class OMNYGalilReadbackSignal(GalilSignalRO):
try:
rt = self.parent.device_manager.devices["rtx"]
if rt.enabled:
rt.obj.controller.set_rotation_angle(val[self.parent.name]["value"]-25+54)
rt.obj.controller.set_rotation_angle(val[self.parent.name]["value"] - 25 + 54)
except KeyError:
logger.warning("Failed to set RT value during ogalil readback.")
logger.warning("Failed to set RT value during ogalil readback.")
return val
class OMNYGalilController(GalilController):
USER_ACCESS = [
"describe",
@@ -132,18 +137,18 @@ class OMNYGalilController(GalilController):
"_ogalil_folerr_not_ignore",
]
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
def on(self) -> None:
OKBLUE = "\033[94m"
OKCYAN = "\033[96m"
OKGREEN = "\033[92m"
WARNING = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
def on(self, timeout: int = 10) -> None:
"""Open a new socket connection to the controller"""
self._ogalil_switchsocket_switch_all_on()
time.sleep(0.3)
super().on()
super().on(timeout=timeout)
def _ogalil_switchsocket(self, number: int, switch: bool):
# number is socket number ranging from 1 to 4
@@ -185,15 +190,16 @@ class OMNYGalilController(GalilController):
self.socket_put_confirmed("IgNoFol=1")
self.socket_put_confirmed("XQ#STOP,1")
def _ogalil_set_axis_to_pos_wo_reference_search(self, axis_id_numeric, axis_id, pos_mm, motor_resolution, motor_sign):
def _ogalil_set_axis_to_pos_wo_reference_search(
self, axis_id_numeric, axis_id, pos_mm, motor_resolution, motor_sign
):
self.socket_put_confirmed("IgNoFol=1")
# pos_mm = pos_encoder / motor_resolution
pos_encoder = pos_mm * motor_resolution * motor_sign
#print(motor_resolution)
# print(motor_resolution)
self.socket_put_confirmed(f"DE{axis_id}={pos_encoder:.0f}")
self.socket_put_confirmed(f"DP{axis_id}=_TP{axis_id}*ratio[{axis_id_numeric:.0f}]")
@@ -203,7 +209,6 @@ class OMNYGalilController(GalilController):
self._ogalil_folerr_not_ignore()
def _ogalil_folerr_not_ignore(self):
self.socket_put_confirmed("IgNoFol=0")
@@ -240,7 +245,18 @@ class OMNYGalilController(GalilController):
class OMNYGalilMotor(Device, PositionerBase):
USER_ACCESS = ["controller", "find_reference", "omny_osamx_to_scan_center", "drive_axis_to_limit", "_ogalil_folerr_reset_and_ignore", "_ogalil_set_axis_to_pos_wo_reference_search", "get_motor_limit_switch", "axis_is_referenced", "get_motor_temperature", "folerr_status"]
USER_ACCESS = [
"controller",
"find_reference",
"omny_osamx_to_scan_center",
"drive_axis_to_limit",
"_ogalil_folerr_reset_and_ignore",
"_ogalil_set_axis_to_pos_wo_reference_search",
"get_motor_limit_switch",
"axis_is_referenced",
"get_motor_temperature",
"folerr_status",
]
readback = Cpt(OMNYGalilReadbackSignal, signal_name="readback", kind="hinted")
user_setpoint = Cpt(GalilSetpointSignal, signal_name="setpoint")
motor_resolution = Cpt(GalilMotorResolution, signal_name="resolution", kind="config")
@@ -272,7 +288,7 @@ class OMNYGalilMotor(Device, PositionerBase):
**kwargs,
):
self.controller = OMNYGalilController(
socket_cls=socket_cls, socket_host=host, socket_port=port
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
)
self.axis_Id = axis_Id
self.controller.set_axis(axis=self, axis_nr=self.axis_Id_numeric)
@@ -308,6 +324,9 @@ class OMNYGalilMotor(Device, PositionerBase):
self.low_limit_travel.put(limits[0])
self.high_limit_travel.put(limits[1])
def wait_for_connection(self, all_signals=False, timeout: float = 30.0) -> bool:
self.controller.on(timeout=timeout)
@property
def limits(self):
return (self.low_limit_travel.get(), self.high_limit_travel.get())
@@ -433,8 +452,10 @@ class OMNYGalilMotor(Device, PositionerBase):
def _ogalil_set_axis_to_pos_wo_reference_search(self, pos_mm):
motor_resolution = self.motor_resolution.get()
self.controller._ogalil_set_axis_to_pos_wo_reference_search(self.axis_Id_numeric, self.axis_Id, pos_mm, motor_resolution, self.sign)
#now force position read to cache
self.controller._ogalil_set_axis_to_pos_wo_reference_search(
self.axis_Id_numeric, self.axis_Id, pos_mm, motor_resolution, self.sign
)
# now force position read to cache
val = self.readback.read()
self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time())
@@ -442,9 +463,9 @@ class OMNYGalilMotor(Device, PositionerBase):
"""
Find the reference of the axis.
"""
verbose=1
verbose = 1
self.controller.find_reference(self.axis_Id_numeric, verbose, raise_error)
#now force position read to cache
# now force position read to cache
val = self.readback.read()
self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time())
@@ -453,10 +474,10 @@ class OMNYGalilMotor(Device, PositionerBase):
Drive an axis to the limit in a specified direction.
Args:
direction (str): Direction in which the axis should be driven to the limit. Either 'forward' or 'reverse'.
direction (str): Direction in which the axis should be driven to the limit. Either 'forward' or 'reverse'.
"""
self.controller.drive_axis_to_limit(self.axis_Id_numeric, direction, verbose=1)
#now force position read to cache
# now force position read to cache
val = self.readback.read()
self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time())
@@ -487,29 +508,31 @@ class OMNYGalilMotor(Device, PositionerBase):
def omny_osamx_to_scan_center(self, cenx):
if self.controller.sock.port == 8082 and self.axis_Id_numeric == 0:
# get last setpoint
osamx = self.device_manager.devices["osamx"]
osamx_current_setpoint = osamx.obj.readback.get()
omny_samx_in = self._get_user_param_safe("osamx","in")
if np.fabs(osamx_current_setpoint-(omny_samx_in+cenx/1000)) > 0.025:
message=f"Moving osamx to scan center. new osamx target {omny_samx_in+cenx/1000:.3f}."
logger.info(message)
osamx = self.device_manager.devices["osamx"]
osamx_current_setpoint = osamx.obj.readback.get()
omny_samx_in = self._get_user_param_safe("osamx", "in")
if np.fabs(osamx_current_setpoint - (omny_samx_in + cenx / 1000)) > 0.025:
message = (
f"Moving osamx to scan center. new osamx target {omny_samx_in+cenx/1000:.3f}."
)
logger.info(message)
osamx.read_only = False
#osamx.controller.("osamx", "controller.socket_put_confirmed('axspeed[0]=1000')")
osamx.set(omny_samx_in+cenx/1000)
time.sleep(0.1)
while(osamx.motor_is_moving.get()):
time.sleep(0.05)
osamx.read_only = True
time.sleep(2)
rt = self.device_manager.devices["rtx"]
if rt.enabled:
rt.obj.controller.laser_tracker_on()
rt.obj.controller.laser_tracker_check_and_wait_for_signalstrength()
osamx.read_only = False
# osamx.controller.("osamx", "controller.socket_put_confirmed('axspeed[0]=1000')")
osamx.set(omny_samx_in + cenx / 1000)
time.sleep(0.1)
while osamx.motor_is_moving.get():
time.sleep(0.05)
osamx.read_only = True
time.sleep(2)
rt = self.device_manager.devices["rtx"]
if rt.enabled:
rt.obj.controller.laser_tracker_on()
rt.obj.controller.laser_tracker_check_and_wait_for_signalstrength()
def folerr_status(self) -> bool:
return self.controller.folerr_status(self.axis_Id_numeric)
def stop(self, *, success=False):
self.controller.stop_all_axes()
return super().stop(success=success)

View File

@@ -52,33 +52,12 @@ class GalilController(Controller):
"fly_grid_scan",
"read_encoder_position",
]
_axes_per_controller = 8
def __init__(
self,
*,
name="GalilController",
kind=None,
parent=None,
socket=None,
attr_name="",
labels=None,
):
if not hasattr(self, "_initialized") or not self._initialized:
self._galil_axis_per_controller = 8
self._axis = [None for axis_num in range(self._galil_axis_per_controller)]
super().__init__(
name=name,
socket=socket,
attr_name=attr_name,
parent=parent,
labels=labels,
kind=kind,
)
def on(self, controller_num=0) -> None:
def on(self, timeout: int = 10) -> None:
"""Open a new socket connection to the controller"""
if not self.connected:
self.sock.open()
self.sock.open(timeout=timeout)
self.connected = True
else:
logger.info("The connection has already been established.")
@@ -165,11 +144,11 @@ class GalilController(Controller):
def show_running_threads(self) -> None:
t = PrettyTable()
t.title = f"Threads on {self.sock.host}:{self.sock.port}"
t.field_names = [str(ax) for ax in range(self._galil_axis_per_controller)]
t.field_names = [str(ax) for ax in range(self._axes_per_controller)]
t.add_row(
[
"active" if self.is_thread_active(t) else "inactive"
for t in range(self._galil_axis_per_controller)
for t in range(self._axes_per_controller)
]
)
print(t)
@@ -199,7 +178,7 @@ class GalilController(Controller):
"Limits",
"Position",
]
for ax in range(self._galil_axis_per_controller):
for ax in range(self._axes_per_controller):
axis = self._axis[ax]
if axis is not None:
t.add_row(
@@ -516,7 +495,9 @@ class SGalilMotor(Device, PositionerBase):
):
self.axis_Id = axis_Id
self.sign = sign
self.controller = GalilController(socket=socket_cls(host=host, port=port))
self.controller = GalilController(
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
)
self.controller.set_axis(axis=self, axis_nr=self.axis_Id_numeric)
self.tolerance = kwargs.pop("tolerance", 0.5)
self.device_mapping = kwargs.pop("device_mapping", {})
@@ -549,6 +530,9 @@ class SGalilMotor(Device, PositionerBase):
self.low_limit_travel.put(limits[0])
self.high_limit_travel.put(limits[1])
def wait_for_connection(self, all_signals=False, timeout: float = 30.0) -> bool:
self.controller.on(timeout=timeout)
@property
def limits(self):
return (self.low_limit_travel.get(), self.high_limit_travel.get())

View File

@@ -37,84 +37,86 @@ class OMNYSampleStorage(Device):
_default_sub = SUB_VALUE
sample_shuttle_A_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}", {}) for i in range(1, 7)
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}", {"auto_monitor": True}) for i in range(1, 7)
}
sample_shuttle_A_placed = Dcpt(sample_shuttle_A_placed)
sample_shuttle_B_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_B:{i}", {}) for i in range(1, 7)
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_B:{i}", {"auto_monitor": True}) for i in range(1, 7)
}
sample_shuttle_B_placed = Dcpt(sample_shuttle_B_placed)
sample_shuttle_C_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}", {}) for i in range(1, 7)
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}", {"auto_monitor": True}) for i in range(1, 7)
}
sample_shuttle_C_placed = Dcpt(sample_shuttle_C_placed)
sample_shuttle_C_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}", {}) for i in range(1, 7)
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}", {"auto_monitor": True}) for i in range(1, 7)
}
sample_shuttle_C_placed = Dcpt(sample_shuttle_C_placed)
parking_placed = {
f"parking{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_parking:{i}", {}) for i in range(1, 7)
f"parking{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_parking:{i}", {"auto_monitor": True}) for i in range(1, 7)
}
parking_placed = Dcpt(parking_placed)
sample_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_omny:{i}", {})
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_omny:{i}", {"auto_monitor": True})
for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]
}
sample_placed = Dcpt(sample_placed)
sample_shuttle_A_names = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}.DESC", {"string": True})
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}.DESC", {"string": True, "auto_monitor": True})
for i in range(1, 7)
}
sample_shuttle_A_names = Dcpt(sample_shuttle_A_names)
sample_shuttle_B_names = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_B:{i}.DESC", {"string": True})
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_B:{i}.DESC", {"string": True, "auto_monitor": True})
for i in range(1, 7)
}
sample_shuttle_B_names = Dcpt(sample_shuttle_B_names)
sample_shuttle_C_names = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}.DESC", {"string": True})
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}.DESC", {"string": True, "auto_monitor": True})
for i in range(1, 7)
}
sample_shuttle_C_names = Dcpt(sample_shuttle_C_names)
parking_names = {
f"parking{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_parking:{i}.DESC", {"string": True})
f"parking{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_parking:{i}.DESC", {"string": True, "auto_monitor": True})
for i in range(1, 7)
}
parking_names = Dcpt(parking_names)
sample_names = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_omny:{i}.DESC", {"string": True})
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_omny:{i}.DESC", {"string": True, "auto_monitor": True})
for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]
}
sample_names = Dcpt(sample_names)
sample_in_gripper = Cpt(
EpicsSignal, name="sample_in_gripper", read_pv="XOMNY-SAMPLE_DB_omny:110.VAL"
EpicsSignal, name="sample_in_gripper", read_pv="XOMNY-SAMPLE_DB_omny:110.VAL", auto_monitor=True
)
sample_in_gripper_name = Cpt(
EpicsSignal,
name="sample_in_gripper_name",
read_pv="XOMNY-SAMPLE_DB_omny:110.DESC",
string=True,
auto_monitor=True
)
sample_in_samplestage = Cpt(
EpicsSignal, name="sample_in_samplestage", read_pv="XOMNY-SAMPLE_DB_omny:0.VAL"
EpicsSignal, name="sample_in_samplestage", read_pv="XOMNY-SAMPLE_DB_omny:0.VAL", auto_monitor=True
)
sample_in_samplestage_name = Cpt(
EpicsSignal,
name="sample_in_samplestage_name",
read_pv="XOMNY-SAMPLE_DB_omny:0.DESC",
string=True,
auto_monitor=True
)
def __init__(self, prefix="", *, name, **kwargs):

View File

@@ -57,6 +57,7 @@ class RtFlomniController(Controller):
socket_cls=None,
socket_host=None,
socket_port=None,
device_manager=None,
attr_name="",
parent=None,
labels=None,
@@ -67,6 +68,7 @@ class RtFlomniController(Controller):
socket_cls=socket_cls,
socket_host=socket_host,
socket_port=socket_port,
device_manager=device_manager,
attr_name=attr_name,
parent=parent,
labels=labels,
@@ -126,15 +128,15 @@ class RtFlomniController(Controller):
while not self.slew_rate_limiters_on_target() or np.abs(self.pid_y()) > 0.1:
time.sleep(0.05)
self.get_device_manager().devices.rty.update_user_parameter({"tomo_additional_offsety": 0})
self.device_manager.devices.rty.update_user_parameter({"tomo_additional_offsety": 0})
self.clear_trajectory_generator()
self.laser_tracker_on()
# move to 0. FUPR will set the rotation angle during readout
self.get_device_manager().devices.fsamroy.obj.move(0, wait=True)
self.device_manager.devices.fsamroy.obj.move(0, wait=True)
fsamx = self.get_device_manager().devices.fsamx
fsamx = self.device_manager.devices.fsamx
fsamx.obj.pid_x_correction = 0
fsamx.obj.controller.socket_put_confirmed("axspeed[4]=0.1*stppermm[4]")
@@ -164,18 +166,18 @@ class RtFlomniController(Controller):
self.show_cyclic_error_compensation()
self.rt_pid_voltage = self.get_pid_x()
rtx = self.get_device_manager().devices.rtx
rtx = self.device_manager.devices.rtx
rtx.update_user_parameter({"rt_pid_voltage": self.rt_pid_voltage})
self.set_device_enabled("fsamx", False)
self.set_device_enabled("fsamy", False)
self.set_device_enabled("foptx", False)
self.set_device_enabled("fopty", False)
self.set_device_read_write("fsamx", False)
self.set_device_read_write("fsamy", False)
self.set_device_read_write("foptx", False)
self.set_device_read_write("fopty", False)
def move_samx_to_scan_region(self, fovx: float, cenx: float):
time.sleep(0.05)
if self.rt_pid_voltage is None:
rtx = self.get_device_manager().devices.rtx
rtx = self.device_manager.devices.rtx
self.rt_pid_voltage = rtx.user_parameter.get("rt_pid_voltage")
if self.rt_pid_voltage is None:
raise RtError(
@@ -192,7 +194,7 @@ class RtFlomniController(Controller):
break
wait_on_exit = True
self.socket_put("v0")
fsamx = self.get_device_manager().devices.fsamx
fsamx = self.device_manager.devices.fsamx
fsamx.read_only = False
fsamx.obj.controller.socket_put_confirmed("axspeed[4]=0.1*stppermm[4]")
fsamx.obj.pid_x_correction -= (self.get_pid_x() - expected_voltage) * 0.007
@@ -223,22 +225,22 @@ class RtFlomniController(Controller):
print("Feedback is not running; likely an error in the interferometer.")
raise RtError("Feedback is not running; likely an error in the interferometer.")
self.set_device_enabled("fsamx", False)
self.set_device_enabled("fsamy", False)
self.set_device_enabled("foptx", False)
self.set_device_enabled("fopty", False)
self.set_device_read_write("fsamx", False)
self.set_device_read_write("fsamy", False)
self.set_device_read_write("foptx", False)
self.set_device_read_write("fopty", False)
def feedback_disable(self):
self.clear_trajectory_generator()
self.move_to_zero()
self.socket_put("l0")
self.set_device_enabled("fsamx", True)
self.set_device_enabled("fsamy", True)
self.set_device_enabled("foptx", True)
self.set_device_enabled("fopty", True)
self.set_device_read_write("fsamx", True)
self.set_device_read_write("fsamy", True)
self.set_device_read_write("foptx", True)
self.set_device_read_write("fopty", True)
fsamx = self.get_device_manager().devices.fsamx
fsamx = self.device_manager.devices.fsamx
fsamx.obj.controller.socket_put_confirmed("axspeed[4]=025*stppermm[4]")
print("rt feedback is now disalbed.")
@@ -289,12 +291,8 @@ class RtFlomniController(Controller):
self.socket_put("T1")
time.sleep(0.5)
self.get_device_manager().devices.ftrackz.obj.controller.socket_put_confirmed(
"trackyct=0"
)
self.get_device_manager().devices.ftrackz.obj.controller.socket_put_confirmed(
"trackzct=0"
)
self.device_manager.devices.ftrackz.obj.controller.socket_put_confirmed("trackyct=0")
self.device_manager.devices.ftrackz.obj.controller.socket_put_confirmed("trackzct=0")
self.laser_tracker_wait_on_target()
logger.info("Laser tracker running!")
@@ -341,7 +339,7 @@ class RtFlomniController(Controller):
}
def laser_tracker_galil_enable(self):
ftrackz_con = self.get_device_manager().devices.ftrackz.obj.controller
ftrackz_con = self.device_manager.devices.ftrackz.obj.controller
ftrackz_con.socket_put_confirmed("tracken=1")
ftrackz_con.socket_put_confirmed("trackyct=0")
ftrackz_con.socket_put_confirmed("trackzct=0")
@@ -389,9 +387,12 @@ class RtFlomniController(Controller):
self.laser_tracker_wait_on_target()
signal = self.read_ssi_interferometer(1)
rtx = self.get_device_manager().devices.rtx
rtx = self.device_manager.devices.rtx
min_signal = rtx.user_parameter.get("min_signal")
low_signal = rtx.user_parameter.get("low_signal")
print(f"low signal: {low_signal}")
print(f"min signal: {min_signal}")
print(f"signal: {signal}")
if signal < min_signal:
time.sleep(1)
if signal < min_signal:
@@ -475,12 +476,6 @@ class RtFlomniController(Controller):
current_position_in_scan = int(float(return_table[2]))
return (mode, number_of_positions_planned, current_position_in_scan)
def get_device_manager(self):
for axis in self._axis:
if hasattr(axis, "device_manager") and axis.device_manager:
return axis.device_manager
raise BECConfigError("Could not access the device_manager")
def read_positions_from_sampler(self):
# this was for reading after the scan completed
number_of_samples_to_read = 1 # self.get_scan_status()[1] #number of valid samples, will be updated upon first data read
@@ -495,7 +490,7 @@ class RtFlomniController(Controller):
# if not (mode==2 or mode==3):
# error
self.get_device_manager().connector.set(
self.device_manager.connector.set(
MessageEndpoints.device_status("rt_scan"),
messages.DeviceStatusMessage(
device="rt_scan", status=1, metadata=self.readout_metadata
@@ -530,7 +525,7 @@ class RtFlomniController(Controller):
signals = self._get_signals_from_table(return_table)
self.publish_device_data(signals=signals, point_id=int(return_table[0]))
self.get_device_manager().connector.set(
self.device_manager.connector.set(
MessageEndpoints.device_status("rt_scan"),
messages.DeviceStatusMessage(
device="rt_scan", status=0, metadata=self.readout_metadata
@@ -544,7 +539,7 @@ class RtFlomniController(Controller):
)
def publish_device_data(self, signals, point_id):
self.get_device_manager().connector.set_and_publish(
self.device_manager.connector.set_and_publish(
MessageEndpoints.device_read("rt_flomni"),
messages.DeviceMessage(
signals=signals, metadata={"point_id": point_id, **self.readout_metadata}
@@ -655,7 +650,7 @@ class RtFlomniMotor(Device, PositionerBase):
self.axis_Id = axis_Id
self.sign = sign
self.controller = RtFlomniController(
socket_cls=socket_cls, socket_host=host, socket_port=port
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
)
self.controller.set_axis(axis=self, axis_nr=self.axis_Id_numeric)
self.device_manager = device_manager
@@ -683,6 +678,9 @@ class RtFlomniMotor(Device, PositionerBase):
self.low_limit_travel.put(limits[0])
self.high_limit_travel.put(limits[1])
def wait_for_connection(self, all_signals=False, timeout: float = 30.0) -> bool:
self.controller.on(timeout=timeout)
@property
def limits(self):
return (self.low_limit_travel.get(), self.high_limit_travel.get())
@@ -810,7 +808,7 @@ class RtFlomniMotor(Device, PositionerBase):
if __name__ == "__main__":
rtcontroller = RtFlomniController(
socket_cls=SocketIO, socket_host="mpc2844.psi.ch", socket_port=2222
socket_cls=SocketIO, socket_host="mpc2844.psi.ch", socket_port=2222, device_manager=None
)
rtcontroller.on()
rtcontroller.laser_tracker_on()

View File

@@ -71,6 +71,7 @@ class RtLamniController(Controller):
socket_cls=None,
socket_host=None,
socket_port=None,
device_manager=None,
attr_name="",
parent=None,
labels=None,
@@ -81,6 +82,7 @@ class RtLamniController(Controller):
socket_cls=socket_cls,
socket_host=socket_host,
socket_port=socket_port,
device_manager=device_manager,
attr_name=attr_name,
parent=parent,
labels=labels,
@@ -92,11 +94,11 @@ class RtLamniController(Controller):
def feedback_disable(self):
self.socket_put("J0")
logger.info("LamNI Feedback disabled.")
self.set_device_enabled("lsamx", True)
self.set_device_enabled("lsamy", True)
self.set_device_enabled("loptx", True)
self.set_device_enabled("lopty", True)
self.set_device_enabled("loptz", True)
self.set_device_read_write("lsamx", True)
self.set_device_read_write("lsamy", True)
self.set_device_read_write("loptx", True)
self.set_device_read_write("lopty", True)
self.set_device_read_write("loptz", True)
def is_axis_moving(self, axis_Id) -> bool:
# this checks that axis is on target
@@ -150,25 +152,25 @@ class RtLamniController(Controller):
# set these as closed loop target position
self.socket_put(f"pa0,{x_curr:.4f}")
self.socket_put(f"pa1,{y_curr:.4f}")
self.get_device_manager().devices.rtx.obj.user_setpoint.set_with_feedback_disabled(x_curr)
self.get_device_manager().devices.rty.obj.user_setpoint.set_with_feedback_disabled(y_curr)
self.device_manager.devices.rtx.obj.user_setpoint.set_with_feedback_disabled(x_curr)
self.device_manager.devices.rty.obj.user_setpoint.set_with_feedback_disabled(y_curr)
self.socket_put("J5")
logger.info("LamNI Feedback enabled (without reset).")
self.set_device_enabled("lsamx", False)
self.set_device_enabled("lsamy", False)
self.set_device_enabled("loptx", False)
self.set_device_enabled("lopty", False)
self.set_device_enabled("loptz", False)
self.set_device_read_write("lsamx", False)
self.set_device_read_write("lsamy", False)
self.set_device_read_write("loptx", False)
self.set_device_read_write("lopty", False)
self.set_device_read_write("loptz", False)
@threadlocked
def feedback_disable_and_even_reset_lamni_angle_interferometer(self):
self.socket_put("J6")
logger.info("LamNI Feedback disabled including the angular interferometer.")
self.set_device_enabled("lsamx", True)
self.set_device_enabled("lsamy", True)
self.set_device_enabled("loptx", True)
self.set_device_enabled("lopty", True)
self.set_device_enabled("loptz", True)
self.set_device_read_write("lsamx", True)
self.set_device_read_write("lsamy", True)
self.set_device_read_write("loptx", True)
self.set_device_read_write("lopty", True)
self.set_device_read_write("loptz", True)
@threadlocked
def clear_trajectory_generator(self):
@@ -284,7 +286,7 @@ class RtLamniController(Controller):
# if not (mode==2 or mode==3):
# error
self.get_device_manager().connector.set(
self.device_manager.connector.set(
MessageEndpoints.device_status("rt_scan"),
messages.DeviceStatusMessage(
device="rt_scan", status=1, metadata=self.readout_metadata
@@ -319,7 +321,7 @@ class RtLamniController(Controller):
signals = self._get_signals_from_table(return_table)
self.publish_device_data(signals=signals, point_id=int(return_table[0]))
self.get_device_manager().connector.set(
self.device_manager.connector.set(
MessageEndpoints.device_status("rt_scan"),
messages.DeviceStatusMessage(
device="rt_scan", status=0, metadata=self.readout_metadata
@@ -331,7 +333,7 @@ class RtLamniController(Controller):
)
def publish_device_data(self, signals, point_id):
self.get_device_manager().connector.set_and_publish(
self.device_manager.connector.set_and_publish(
MessageEndpoints.device_read("rt_lamni"),
messages.DeviceMessage(
signals=signals, metadata={"point_id": point_id, **self.readout_metadata}
@@ -366,10 +368,10 @@ class RtLamniController(Controller):
) # we set all three outputs of the traj. gen. although in LamNI case only 0,1 are used
self.clear_trajectory_generator()
self.get_device_manager().devices.lsamrot.obj.move(0, wait=True)
self.device_manager.devices.lsamrot.obj.move(0, wait=True)
galil_controller_rt_status = (
self.get_device_manager().devices.lsamx.obj.controller.lgalil_is_air_off_and_orchestra_enabled()
self.device_manager.devices.lsamx.obj.controller.lgalil_is_air_off_and_orchestra_enabled()
)
if galil_controller_rt_status == 0:
@@ -382,16 +384,16 @@ class RtLamniController(Controller):
time.sleep(0.03)
lsamx_user_params = self.get_device_manager().devices.lsamx.user_parameter
lsamx_user_params = self.device_manager.devices.lsamx.user_parameter
if lsamx_user_params is None or lsamx_user_params.get("center") is None:
raise RuntimeError("lsamx center is not defined")
lsamy_user_params = self.get_device_manager().devices.lsamy.user_parameter
lsamy_user_params = self.device_manager.devices.lsamy.user_parameter
if lsamy_user_params is None or lsamy_user_params.get("center") is None:
raise RuntimeError("lsamy center is not defined")
lsamx_center = lsamx_user_params.get("center")
lsamy_center = lsamy_user_params.get("center")
self.get_device_manager().devices.lsamx.obj.move(lsamx_center, wait=True)
self.get_device_manager().devices.lsamy.obj.move(lsamy_center, wait=True)
self.device_manager.devices.lsamx.obj.move(lsamx_center, wait=True)
self.device_manager.devices.lsamy.obj.move(lsamy_center, wait=True)
self.socket_put("J1")
_waitforfeedbackctr = 0
@@ -405,11 +407,11 @@ class RtLamniController(Controller):
(self.socket_put_and_receive("J2")).split(",")[0]
)
self.set_device_enabled("lsamx", False)
self.set_device_enabled("lsamy", False)
self.set_device_enabled("loptx", False)
self.set_device_enabled("lopty", False)
self.set_device_enabled("loptz", False)
self.set_device_read_write("lsamx", False)
self.set_device_read_write("lsamy", False)
self.set_device_read_write("loptx", False)
self.set_device_read_write("lopty", False)
self.set_device_read_write("loptz", False)
if interferometer_feedback_not_running == 1:
logger.error(
@@ -559,7 +561,7 @@ class RtLamniMotor(Device, PositionerBase):
self.axis_Id = axis_Id
self.sign = sign
self.controller = RtLamniController(
socket_cls=socket_cls, socket_host=host, socket_port=port
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
)
self.controller.set_axis(axis=self, axis_nr=self.axis_Id_numeric)
self.device_manager = device_manager
@@ -586,6 +588,9 @@ class RtLamniMotor(Device, PositionerBase):
self.low_limit_travel.put(limits[0])
self.high_limit_travel.put(limits[1])
def wait_for_connection(self, all_signals=False, timeout: float = 30.0) -> bool:
self.controller.on(timeout=timeout)
@property
def limits(self):
return (self.low_limit_travel.get(), self.high_limit_travel.get())

View File

@@ -1,8 +1,8 @@
import builtins
import socket
import threading
import time
from typing import List
import builtins
import socket
import numpy as np
from bec_lib import bec_logger, messages
@@ -34,12 +34,15 @@ from csaxs_bec.devices.omny.rt.rt_ophyd import (
logger = bec_logger.logger
class RtOMNY_mirror_switchbox_Error(Exception):
pass
class RtOMNY_Error(Exception):
pass
class RtOMNYController(Controller):
_axes_per_controller = 3
red = "\x1b[91m"
@@ -87,6 +90,7 @@ class RtOMNYController(Controller):
socket_cls=None,
socket_host=None,
socket_port=None,
device_manager=None,
attr_name="",
parent=None,
labels=None,
@@ -97,6 +101,7 @@ class RtOMNYController(Controller):
socket_cls=socket_cls,
socket_host=socket_host,
socket_port=socket_port,
device_manager=device_manager,
attr_name=attr_name,
parent=parent,
labels=labels,
@@ -234,7 +239,7 @@ class RtOMNYController(Controller):
"opt_amplitude1_neg": 3000,
"opt_amplitude2_pos": 3000,
"opt_amplitude2_neg": 3000,
}
},
}
# def is_axis_moving(self, axis_Id) -> bool:
@@ -261,42 +266,60 @@ class RtOMNYController(Controller):
threading.Thread(target=send_positions, args=(self, positions), daemon=True).start()
def get_mirror_parameters(self,channel):
def get_mirror_parameters(self, channel):
return self.mirror_parameters[channel]
def laser_tracker_check_and_wait_for_signalstrength(self):
self.get_device_manager().connector.send_client_info("Checking laser tracker...", scope="", show_asap=True)
self.device_manager.connector.send_client_info(
"Checking laser tracker...", scope="", show_asap=True
)
if not self.laser_tracker_check_enabled():
print("laser_tracker_check_and_wait_for_signalstrength: The laser tracker is not even enabled.")
print(
"laser_tracker_check_and_wait_for_signalstrength: The laser tracker is not even enabled."
)
return
#first check on target
# first check on target
self.laser_tracker_wait_on_target()
#when on target, check interferometer signal
signal = self._omny_interferometer_get_signalsample("ssi_4",0.1)
rtx = self.get_device_manager().devices.rtx
# when on target, check interferometer signal
signal = self._omny_interferometer_get_signalsample("ssi_4", 0.1)
rtx = self.device_manager.devices.rtx
min_signal = rtx.user_parameter.get("min_signal")
low_signal = rtx.user_parameter.get("low_signal")
wait_counter = 0
while signal < min_signal and wait_counter<10:
self.get_device_manager().connector.send_client_info(f"The signal of the tracker {signal} is below the minimum required signal of {min_signal}. Waiting...", scope="laser_tracker_check_and_wait_for_signalstrength", show_asap=True)
while signal < min_signal and wait_counter < 10:
self.device_manager.connector.send_client_info(
f"The signal of the tracker {signal} is below the minimum required signal of {min_signal}. Waiting...",
scope="laser_tracker_check_and_wait_for_signalstrength",
show_asap=True,
)
wait_counter+=1
wait_counter += 1
time.sleep(0.2)
signal = self._omny_interferometer_get_signalsample("ssi_4",0.1)
signal = self._omny_interferometer_get_signalsample("ssi_4", 0.1)
if signal < low_signal:
self.get_device_manager().connector.send_client_info(f"\x1b[91mThe signal of the tracker {signal} is below the low limit of {low_signal}. Auto readjustment...\x1b[0m", scope="laser_tracker_check_and_wait_for_signalstrength", show_asap=True)
self.device_manager.connector.send_client_info(
f"\x1b[91mThe signal of the tracker {signal} is below the low limit of {low_signal}. Auto readjustment...\x1b[0m",
scope="laser_tracker_check_and_wait_for_signalstrength",
show_asap=True,
)
self.omny_interferometer_align_tracking()
self.get_device_manager().connector.send_client_info("Checking laser tracker completed.", scope="", show_asap=True)
self.omny_interferometer_align_tracking()
self.device_manager.connector.send_client_info(
"Checking laser tracker completed.", scope="", show_asap=True
)
def omny_interferometer_align_tracking(self):
mirror_channel=6
signal = self._omny_interferometer_get_signalsample(self.mirror_parameters[mirror_channel]["opt_signalchannel"], self.mirror_parameters[mirror_channel]["opt_averaging_time"])
mirror_channel = 6
signal = self._omny_interferometer_get_signalsample(
self.mirror_parameters[mirror_channel]["opt_signalchannel"],
self.mirror_parameters[mirror_channel]["opt_averaging_time"],
)
if signal > self.mirror_parameters[mirror_channel]["opt_signal_stop"]:
print(f"Interferometer signal of axis {self.mirror_parameters[mirror_channel]['opt_mirrorname']} is good, no alignment needed.")
print(
f"Interferometer signal of axis {self.mirror_parameters[mirror_channel]['opt_mirrorname']} is good, no alignment needed."
)
else:
self._omny_interferometer_switch_channel(mirror_channel)
time.sleep(0.1)
@@ -307,16 +330,19 @@ class RtOMNYController(Controller):
self._omny_interferometer_switch_alloff()
self.show_signal_strength_interferometer()
mirror_channel=-1
mirror_channel = -1
def omny_interferometer_align_incoupling_angle(self):
mirror_channel=1
signal = self._omny_interferometer_get_signalsample(self.mirror_parameters[mirror_channel]["opt_signalchannel"], self.mirror_parameters[mirror_channel]["opt_averaging_time"])
mirror_channel = 1
signal = self._omny_interferometer_get_signalsample(
self.mirror_parameters[mirror_channel]["opt_signalchannel"],
self.mirror_parameters[mirror_channel]["opt_averaging_time"],
)
if signal > self.mirror_parameters[mirror_channel]["opt_signal_stop"]:
print(f"Interferometer signal of axis {self.mirror_parameters[mirror_channel]['opt_mirrorname']} is good, no alignment needed.")
print(
f"Interferometer signal of axis {self.mirror_parameters[mirror_channel]['opt_mirrorname']} is good, no alignment needed."
)
else:
self._omny_interferometer_switch_channel(mirror_channel)
time.sleep(0.1)
@@ -327,19 +353,18 @@ class RtOMNYController(Controller):
self._omny_interferometer_switch_alloff()
self.show_signal_strength_interferometer()
mirror_channel=-1
mirror_channel = -1
def _omny_interferometer_openloop_steps(self, channel, steps, amplitude):
if channel not in range(3,5):
if channel not in range(3, 5):
raise RtOMNY_Error(f"invalid channel number {channel}.")
if amplitude > 4090:
amplitude = 4090
elif amplitude < 10:
amplitude = 10
oshield = self.get_device_manager().devices.oshield
oshield = self.device_manager.devices.oshield
oshield.obj.controller.move_open_loop_steps(
channel, steps, amplitude=amplitude, frequency=500
@@ -351,7 +376,7 @@ class RtOMNYController(Controller):
def _omny_interferometer_optimize(self, mirror_channel, channel):
if mirror_channel == -1:
raise RtOMNY_Error("no mirror channel selected")
#mirror channel is mirror number and channel is smaract channel, i.e. axis of the mirror
# mirror channel is mirror number and channel is smaract channel, i.e. axis of the mirror
if channel == 3:
steps_pos = self.mirror_parameters[mirror_channel]["opt_steps1_pos"]
steps_neg = self.mirror_parameters[mirror_channel]["opt_steps1_neg"]
@@ -365,67 +390,80 @@ class RtOMNYController(Controller):
else:
raise RtOMNY_Error(f"invalid channel number {channel}.")
previous_signal = self._omny_interferometer_get_signalsample(self.mirror_parameters[mirror_channel]["opt_signalchannel"], self.mirror_parameters[mirror_channel]["opt_averaging_time"])
previous_signal = self._omny_interferometer_get_signalsample(
self.mirror_parameters[mirror_channel]["opt_signalchannel"],
self.mirror_parameters[mirror_channel]["opt_averaging_time"],
)
min_begin = self.mirror_parameters[mirror_channel]["opt_signal_min_begin"]
min_begin = self.mirror_parameters[mirror_channel]["opt_signal_min_begin"]
if previous_signal < min_begin:
#raise OMNY_rt_clientError("error1") #(f"Minimum signal of axis {self.mirror_parameters[mirror_channel]["opt_mirrorname"]} to start alignment not present.")
# raise OMNY_rt_clientError("error1") #(f"Minimum signal of axis {self.mirror_parameters[mirror_channel]["opt_mirrorname"]} to start alignment not present.")
print(f"\rMinimum signal for auto alignment {min_begin} not reached.")
return
elif previous_signal > self.mirror_parameters[mirror_channel]["opt_signal_stop"]:
print(f"\rInterferometer signal of axis is good") # {self.mirror_parameters[mirror_channel]["opt_mirrorname"]} is good.")
return
print(
f"\rInterferometer signal of axis is good"
) # {self.mirror_parameters[mirror_channel]["opt_mirrorname"]} is good.")
return
else:
direction = 1
cycle_counter=0
cycle_max=20
reversal_counter=0
reversal_max=4
self.mirror_amplitutde_increase=0
cycle_counter = 0
cycle_max = 20
reversal_counter = 0
reversal_max = 4
self.mirror_amplitutde_increase = 0
current_sample = self._omny_interferometer_get_signalsample(self.mirror_parameters[mirror_channel]["opt_signalchannel"], self.mirror_parameters[mirror_channel]["opt_averaging_time"])
max=current_sample
current_sample = self._omny_interferometer_get_signalsample(
self.mirror_parameters[mirror_channel]["opt_signalchannel"],
self.mirror_parameters[mirror_channel]["opt_averaging_time"],
)
max = current_sample
while current_sample < self.mirror_parameters[mirror_channel]["opt_signal_stop"] and cycle_counter<cycle_max and reversal_counter < reversal_max:
while (
current_sample < self.mirror_parameters[mirror_channel]["opt_signal_stop"]
and cycle_counter < cycle_max
and reversal_counter < reversal_max
):
# if current_sample < self.mirror_parameters[mirror_channel]["opt_signal_min_begin"]:
# raise OMNY_rt_clientError("error2") #(f"Minimum signal of axis {self.mirror_parameters[mirror_channel]["opt_mirrorname"]} to start alignment not present.")
if direction>0:
if direction > 0:
self._omny_interferometer_openloop_steps(channel, steps_pos, opt_amplitude_pos)
verbose_str = f"channel {channel}, steps {steps_pos}"
else:
self._omny_interferometer_openloop_steps(channel, -steps_neg, opt_amplitude_neg)
verbose_str = f"auto action {channel}, steps {-steps_pos}"
#print(f"Aligning axis ") #{self.mirror_parameters[mirror_channel]["opt_mirrorname"]}. Target: {self.mirror_parameters[mirror_channel]["opt_signal_stop"]}, current {current_sample}")
# print(f"Aligning axis ") #{self.mirror_parameters[mirror_channel]["opt_mirrorname"]}. Target: {self.mirror_parameters[mirror_channel]["opt_signal_stop"]}, current {current_sample}")
current_sample = self._omny_interferometer_get_signalsample(self.mirror_parameters[mirror_channel]["opt_signalchannel"], self.mirror_parameters[mirror_channel]["opt_averaging_time"])
current_sample = self._omny_interferometer_get_signalsample(
self.mirror_parameters[mirror_channel]["opt_signalchannel"],
self.mirror_parameters[mirror_channel]["opt_averaging_time"],
)
opt_mirrorname = self.mirror_parameters[mirror_channel]["opt_mirrorname"]
info_str = f"\rAuto aligning Channel {mirror_channel}, {opt_mirrorname}, Current signal: {current_sample:.0f}"
message=info_str +" (q)uit \r"
self.get_device_manager().connector.send_client_info(message, scope="_omny_interferometer_optimize", show_asap=True)
if previous_signal>current_sample:
if direction<0:
steps_pos=int(steps_pos/2)
steps_neg=int(steps_neg/2)
if steps_pos<1:
steps_pos=1
if steps_neg<1:
steps_neg=1
direction=direction*(-1)
reversal_counter+=1
previous_signal=current_sample
cycle_counter+=1
print(f"\r\nFinished aligning channel {channel} of mirror {mirror_channel}\n\r") # {self.mirror_parameters[mirror_channel]["opt_mirrorname"]}. Target: {self.mirror_parameters[mirror_channel]["opt_signal_stop"]}, current {current_sample}")
message = info_str + " (q)uit \r"
self.device_manager.connector.send_client_info(
message, scope="_omny_interferometer_optimize", show_asap=True
)
if previous_signal > current_sample:
if direction < 0:
steps_pos = int(steps_pos / 2)
steps_neg = int(steps_neg / 2)
if steps_pos < 1:
steps_pos = 1
if steps_neg < 1:
steps_neg = 1
direction = direction * (-1)
reversal_counter += 1
previous_signal = current_sample
cycle_counter += 1
print(
f"\r\nFinished aligning channel {channel} of mirror {mirror_channel}\n\r"
) # {self.mirror_parameters[mirror_channel]["opt_mirrorname"]}. Target: {self.mirror_parameters[mirror_channel]["opt_signal_stop"]}, current {current_sample}")
def move_to_zero(self):
self.socket_put("pa0,0")
@@ -457,7 +495,7 @@ class RtOMNYController(Controller):
if ret == 1:
return True
return False
def feedback_is_running(self) -> bool:
self.feedback_get_status_and_ssi()
interferometer_feedback_not_running = int(self.ssi["feedback_error"])
@@ -466,7 +504,9 @@ class RtOMNYController(Controller):
return True
def feedback_enable_with_reset(self):
self.get_device_manager().connector.send_client_info(f"Enabling the feedback...", scope="", show_asap=True)
self.device_manager.connector.send_client_info(
f"Enabling the feedback...", scope="", show_asap=True
)
self.socket_put("J0") # disable feedback
time.sleep(0.01)
@@ -485,14 +525,16 @@ class RtOMNYController(Controller):
self.laser_tracker_on()
time.sleep(0.01)
osamroy = self.get_device_manager().devices.osamroy
osamroy = self.device_manager.devices.osamroy
# the following read will also upate the angle in rt during readout
readback = osamroy.obj.readback.get()
if (np.fabs(readback) > 0.1):
self.get_device_manager().connector.send_client_info(f"Rotating to zero", scope="feedback enable", show_asap=True)
if np.fabs(readback) > 0.1:
self.device_manager.connector.send_client_info(
f"Rotating to zero", scope="feedback enable", show_asap=True
)
osamroy.obj.move(0, wait=True)
osamx = self.get_device_manager().devices.osamx
osamx = self.device_manager.devices.osamx
osamx_in = osamx.user_parameter.get("in")
if not np.isclose(osamx.obj.readback.get(), osamx_in, atol=0.01):
@@ -514,16 +556,15 @@ class RtOMNYController(Controller):
time.sleep(1.5)
self.set_device_enabled("osamx", False)
self.set_device_enabled("osamy", False)
self.set_device_enabled("ofzpx", False)
self.set_device_enabled("ofzpy", False)
self.set_device_enabled("oosax", False)
self.set_device_enabled("oosax", False)
self.set_device_read_write("osamx", False)
self.set_device_read_write("osamy", False)
self.set_device_read_write("ofzpx", False)
self.set_device_read_write("ofzpy", False)
self.set_device_read_write("oosax", False)
self.set_device_read_write("oosax", False)
print("Feedback is running.")
@threadlocked
def clear_trajectory_generator(self):
self.socket_put("sc")
@@ -534,16 +575,15 @@ class RtOMNYController(Controller):
self.move_to_zero()
self.socket_put("J0")
self.set_device_enabled("osamx", True)
self.set_device_enabled("osamy", True)
self.set_device_enabled("ofzpx", True)
self.set_device_enabled("ofzpy", True)
self.set_device_enabled("oosax", True)
self.set_device_enabled("oosax", True)
self.set_device_read_write("osamx", True)
self.set_device_read_write("osamy", True)
self.set_device_read_write("ofzpx", True)
self.set_device_read_write("ofzpy", True)
self.set_device_read_write("oosax", True)
self.set_device_read_write("oosax", True)
print("rt feedback is now disabled.")
def set_rotation_angle(self, val: float) -> None:
self.socket_put(f"a{val/180*np.pi}")
@@ -578,12 +618,13 @@ class RtOMNYController(Controller):
"enabled_z": bool(tracker_values[10]),
}
def laser_tracker_on(self):
#update variables and enable if not yet active
# update variables and enable if not yet active
if not self.laser_tracker_check_enabled():
logger.info("Enabling the laser tracker. Please wait...")
self.get_device_manager().connector.send_client_info(f"Enabling the laser tracker. Please wait...", scope="", show_asap=True)
self.device_manager.connector.send_client_info(
f"Enabling the laser tracker. Please wait...", scope="", show_asap=True
)
tracker_intensity = self.tracker_info["tracker_intensity"]
if (
@@ -598,18 +639,13 @@ class RtOMNYController(Controller):
self.socket_put("T1")
time.sleep(0.5)
self.get_device_manager().devices.otracky.obj.controller.socket_put_confirmed(
"trackyct=0"
)
self.get_device_manager().devices.otracky.obj.controller.socket_put_confirmed(
"trackzct=0"
)
self.device_manager.devices.otracky.obj.controller.socket_put_confirmed("trackyct=0")
self.device_manager.devices.otracky.obj.controller.socket_put_confirmed("trackzct=0")
self.laser_tracker_wait_on_target()
logger.info("Laser tracker running!")
print("Laser tracker running!")
def laser_tracker_check_enabled(self) -> bool:
self.laser_update_tracker_info()
if self.tracker_info["enabled_z"] and self.tracker_info["enabled_y"]:
@@ -628,11 +664,10 @@ class RtOMNYController(Controller):
return True
return False
def laser_tracker_wait_on_target(self):
max_repeat = 15
count = 0
while not self.laser_tracker_check_on_target() and count<max_repeat:
while not self.laser_tracker_check_on_target() and count < max_repeat:
logger.info("Waiting for laser tracker to reach target position.")
time.sleep(0.5)
count += 1
@@ -641,75 +676,74 @@ class RtOMNYController(Controller):
raise RtError("Failed to reach laser target position.")
def laser_tracker_print_intensity_for_otrack_tweaking(self):
#self.laser_update_tracker_info()
#_laser_tracker_intensity = self.tracker_info["tracker_intensity"]
#print(f"\r PSD beam intensity: {_laser_tracker_intensity:.2f}\r")
# self.laser_update_tracker_info()
# _laser_tracker_intensity = self.tracker_info["tracker_intensity"]
# print(f"\r PSD beam intensity: {_laser_tracker_intensity:.2f}\r")
self.laser_tracker_show_all(extra_endline="\r")
def laser_tracker_show_all(self,extra_endline=""):
def laser_tracker_show_all(self, extra_endline=""):
self.laser_update_tracker_info()
enabled_y = self.tracker_info["enabled_y"]
print(extra_endline+f"Tracker enabled: {bool(enabled_y)}"+extra_endline)
print(extra_endline + f"Tracker enabled: {bool(enabled_y)}" + extra_endline)
if self.tracker_info["tracker_intensity"] < self.tracker_info["threshold_intensity_y"]:
print(self.red+" LOW INTENSITY"+self.white+extra_endline)
print(self.red + " LOW INTENSITY" + self.white + extra_endline)
_laser_tracker_intensity = self.tracker_info["tracker_intensity"]
print(f" PSD beam intensity: {_laser_tracker_intensity:.2f}"+extra_endline)
print(f" PSD beam intensity: {_laser_tracker_intensity:.2f}" + extra_endline)
_laser_tracker_y_beampos = self.tracker_info["beampos_y"]
print(f" Y beam position: {_laser_tracker_y_beampos:.2f}"+extra_endline)
print(f" Y beam position: {_laser_tracker_y_beampos:.2f}" + extra_endline)
_laser_tracker_y_target = self.tracker_info["target_y"]
print(f" target position: {_laser_tracker_y_target:.2f}"+extra_endline)
print(f" target position: {_laser_tracker_y_target:.2f}" + extra_endline)
_laser_tracker_y_threshold_intensity = self.tracker_info["threshold_intensity_y"]
print(f" threshold intensity: {_laser_tracker_y_threshold_intensity:.2f}"+extra_endline)
print(
f" threshold intensity: {_laser_tracker_y_threshold_intensity:.2f}" + extra_endline
)
_laser_tracker_y_piezo_voltage = self.tracker_info["piezo_voltage_y"]
print(f" Piezo voltage: {_laser_tracker_y_piezo_voltage:.2f}"+extra_endline)
print(f" Piezo voltage: {_laser_tracker_y_piezo_voltage:.2f}" + extra_endline)
_laser_tracker_z_beampos = self.tracker_info["beampos_z"]
print(f" Z beam position: {_laser_tracker_z_beampos:.2f}"+extra_endline)
print(f" Z beam position: {_laser_tracker_z_beampos:.2f}" + extra_endline)
_laser_tracker_z_target = self.tracker_info["target_z"]
print(f" target position: {_laser_tracker_z_target:.2f}"+extra_endline)
print(f" target position: {_laser_tracker_z_target:.2f}" + extra_endline)
_laser_tracker_z_threshold_intensity = self.tracker_info["threshold_intensity_z"]
print(f" threshold intensity: {_laser_tracker_z_threshold_intensity:.2f}"+extra_endline)
print(
f" threshold intensity: {_laser_tracker_z_threshold_intensity:.2f}" + extra_endline
)
_laser_tracker_z_piezo_voltage = self.tracker_info["piezo_voltage_z"]
print(f" Piezo voltage: {_laser_tracker_z_piezo_voltage:.2f}"+extra_endline)
print(" Reminder - there is also an upper threshold active in rt\n"+extra_endline)
print(f" Piezo voltage: {_laser_tracker_z_piezo_voltage:.2f}" + extra_endline)
print(" Reminder - there is also an upper threshold active in rt\n" + extra_endline)
if extra_endline == "":
self.laser_tracker_galil_status()
def laser_tracker_galil_enable(self):
otracky_con = self.get_device_manager().devices.otracky.obj.controller
otracky_con = self.device_manager.devices.otracky.obj.controller
otracky_con.socket_put_confirmed("tracken=1")
otracky_con.socket_put_confirmed("trackyct=0")
otracky_con.socket_put_confirmed("trackzct=0")
def laser_tracker_galil_disable(self):
otracky_con = self.get_device_manager().devices.otracky.obj.controller
otracky_con = self.device_manager.devices.otracky.obj.controller
otracky_con.socket_put_confirmed("tracken=0")
def laser_tracker_galil_status(self):
otracky_con = self.get_device_manager().devices.otracky.obj.controller
otracky_con = self.device_manager.devices.otracky.obj.controller
if bool(float(otracky_con.socket_put_and_receive("MGtracken").strip())) == 0:
print(self.red+"Tracking in the Galil Controller is disabled."+self.white)
print(self.red + "Tracking in the Galil Controller is disabled." + self.white)
print("Use dev.rtx.controller.laser_tracker_galil_enable to enable.\n")
return(0)
return 0
else:
print("Tracking in the Galil Controller is enabled.")
trackyct=int(float(otracky_con.socket_put_and_receive("MGtrackyct").strip()))
trackzct=int(float(otracky_con.socket_put_and_receive("MGtrackzct").strip()))
trackyct = int(float(otracky_con.socket_put_and_receive("MGtrackyct").strip()))
trackzct = int(float(otracky_con.socket_put_and_receive("MGtrackzct").strip()))
print(f"Galil Trackcounters y={trackyct}, z={trackzct}")
def show_signal_strength_interferometer(self):
channelnames={1:"OSA FZP Y",2:"ST OSA Y",3:"OSA FZP X",4:"ST OSA X",5:"Angle"}
channelnames = {1: "OSA FZP Y", 2: "ST OSA Y", 3: "OSA FZP X", 4: "ST OSA X", 5: "Angle"}
self.feedback_get_status_and_ssi()
t = PrettyTable()
t.title = f"Interferometer signal strength"
t.field_names = ["Channel", "Name", "Value"]
for i in range(1,6):
for i in range(1, 6):
ssi = self.ssi[f"ssi_{i}"]
t.add_row([i,channelnames[i], ssi])
t.add_row([i, channelnames[i], ssi])
print(t)
def _omny_interferometer_switch_open_socket(self):
@@ -722,44 +756,42 @@ class RtOMNYController(Controller):
self._omny_interferometer_switch_put_and_receive("?000\r")
time.sleep(1)
def _omny_interferometer_switch_channel(self, channel):
self._omny_interferometer_switch_alloff()
time.sleep(0.1)
if channel == 1: #Relais 1 and 2
if channel == 1: # Relais 1 and 2
rback = self._omny_interferometer_switch_put_and_receive("!0020003\r")
#if "|0003\r" != self._omny_interferometer_switch_put_and_receive("!0020003\r"):
# if "|0003\r" != self._omny_interferometer_switch_put_and_receive("!0020003\r"):
# raise RtOMNY_mirror_switchbox_Error("Channel switching failed.")
elif channel == 2: #Relais 3 and 4
elif channel == 2: # Relais 3 and 4
rback = self._omny_interferometer_switch_put_and_receive("!002000C\r")
# if "|000C\r" != self._omny_interferometer_switch_put_and_receive("!002000C\r"):
# raise RtOMNY_mirror_switchbox_Error("Channel switching failed.")
elif channel == 3: #Relais 5 and 6
elif channel == 3: # Relais 5 and 6
rback = self._omny_interferometer_switch_put_and_receive("!0020030\r")
# if "|0030\r" != self._omny_interferometer_switch_put_and_receive("!0020030\r"):
# raise RtOMNY_mirror_switchbox_Error("Channel switching failed.")
elif channel == 4: #Relais 7 and 8
elif channel == 4: # Relais 7 and 8
rback = self._omny_interferometer_switch_put_and_receive("!00200C0\r")
# if "|00C0\r" != self._omny_interferometer_switch_put_and_receive("!00200C0\r"):
# raise RtOMNY_mirror_switchbox_Error("Channel switching failed.")
elif channel == 5: #Relais 9 and 10
elif channel == 5: # Relais 9 and 10
rback = self._omny_interferometer_switch_put_and_receive("!0020300\r")
# if "|0300\r" != self._omny_interferometer_switch_put_and_receive("!0020300\r"):
# raise RtOMNY_mirror_switchbox_Error("Channel switching failed.")
elif channel == 6: #Relais 11 and 12
elif channel == 6: # Relais 11 and 12
rback = self._omny_interferometer_switch_put_and_receive("!0020C00\r")
# if "|0C00\r" != self._omny_interferometer_switch_put_and_receive("!0020C00\r"):
# raise RtOMNY_mirror_switchbox_Error("Channel switching failed.")
elif channel == 7: #Relais 13 and 14
elif channel == 7: # Relais 13 and 14
rback = self._omny_interferometer_switch_put_and_receive("!0023000\r")
# if "|3000\r" != self._omny_interferometer_switch_put_and_receive("!0023000\r"):
# raise RtOMNY_mirror_switchbox_Error("Channel switching failed.")
elif channel == 8: #Relais 7 and 8 SPECIAL CASE use osafzp y signal to align osa y
elif channel == 8: # Relais 7 and 8 SPECIAL CASE use osafzp y signal to align osa y
rback = self._omny_interferometer_switch_put_and_receive("!00200C0\r")
# if "|00C0\r" != self._omny_interferometer_switch_put_and_receive("!00200C0\r"):
# raise RtOMNY_mirror_switchbox_Error("Channel switching failed.")
elif channel == 9: #Relais 15 and 16
elif channel == 9: # Relais 15 and 16
rback = self._omny_interferometer_switch_put_and_receive("!002C000\r")
# if "|C000\r" != self._omny_interferometer_switch_put_and_receive("!002C000\r"):
# raise RtOMNY_mirror_switchbox_Error("Channel switching failed.")
@@ -785,14 +817,14 @@ class RtOMNYController(Controller):
self._omny_interferometer_switch_put_and_receive("!00S01\r")
def _omny_interferometer_switch_alloff(self):
#switch all off
# switch all off
self._omny_interferometer_switch_put_and_receive("!0020000\r")
#LED OFF
# LED OFF
time.sleep(0.1)
self._omny_interferometer_switch_put_and_receive("!00S00\r")
self._omny_interferometer_switch_put_and_receive("!00S00\r")
time.sleep(0.1)
alloff = self._omny_interferometer_switch_put_and_receive("?002\r")
#check all off
alloff = self._omny_interferometer_switch_put_and_receive("?002\r")
# check all off
if "00" not in alloff:
raise RtOMNY_mirror_switchbox_Error("Not all channels switched off.")
@@ -800,17 +832,16 @@ class RtOMNYController(Controller):
self.socket_put("J3")
def _omny_interferometer_get_signalsample(self, channel, averaging_duration):
#channel is string, eg ssi_1
#ensure no averaging running currently
# channel is string, eg ssi_1
# ensure no averaging running currently
self.feedback_is_running()
#measure first sample
# measure first sample
self._rt_start_averaging_SSI()
time.sleep(averaging_duration)
self.feedback_is_running()
return self.ssi[channel]
def _get_signals_from_table(self, return_table) -> dict:
self.average_stdeviations_x_st_fzp += float(return_table[16])
self.average_stdeviations_y_st_fzp += float(return_table[18])
@@ -831,7 +862,6 @@ class RtOMNYController(Controller):
"stdev_x_st_fzp": {"value": float(return_table[16])},
"average_y_st_fzp": {"value": float(return_table[17])},
"stdev_y_st_fzp": {"value": float(return_table[18])},
"average_stdeviations_x_st_fzp": {
"value": self.average_stdeviations_x_st_fzp / (int(return_table[0]) + 1)
},
@@ -840,7 +870,7 @@ class RtOMNYController(Controller):
},
}
return signals
@threadlocked
def start_scan(self):
if not self.feedback_is_running():
@@ -862,7 +892,6 @@ class RtOMNYController(Controller):
# start a point-by-point scan (for cont scan in flomni it would be "sa")
self.socket_put_and_receive("sd")
@retry_once
@threadlocked
def get_scan_status(self):
@@ -881,13 +910,6 @@ class RtOMNYController(Controller):
current_position_in_scan = int(float(return_table[2]))
return (mode, number_of_positions_planned, current_position_in_scan)
def get_device_manager(self):
for axis in self._axis:
if hasattr(axis, "device_manager") and axis.device_manager:
return axis.device_manager
raise BECConfigError("Could not access the device_manager")
def read_positions_from_sampler(self):
# this was for reading after the scan completed
number_of_samples_to_read = 1 # self.get_scan_status()[1] #number of valid samples, will be updated upon first data read
@@ -901,7 +923,7 @@ class RtOMNYController(Controller):
# if not (mode==2 or mode==3):
# error
self.get_device_manager().connector.set(
self.device_manager.connector.set(
MessageEndpoints.device_status("rt_scan"),
messages.DeviceStatusMessage(
device="rt_scan", status=1, metadata=self.readout_metadata
@@ -936,7 +958,7 @@ class RtOMNYController(Controller):
signals = self._get_signals_from_table(return_table)
self.publish_device_data(signals=signals, point_id=int(return_table[0]))
self.get_device_manager().connector.set(
self.device_manager.connector.set(
MessageEndpoints.device_status("rt_scan"),
messages.DeviceStatusMessage(
device="rt_scan", status=0, metadata=self.readout_metadata
@@ -949,15 +971,16 @@ class RtOMNYController(Controller):
f" {self.average_stdeviations_y_st_fzp/read_counter*1000:.1f}."
)
self.get_device_manager().connector.send_client_info(
self.device_manager.connector.send_client_info(
"OMNY statistics: Average of all standard deviations [nm]: x"
f" {self.average_stdeviations_x_st_fzp/read_counter*1000:.1f}, y"
f" {self.average_stdeviations_y_st_fzp/read_counter*1000:.1f}.",
scope="", show_asap=True)
scope="",
show_asap=True,
)
def publish_device_data(self, signals, point_id):
self.get_device_manager().connector.set_and_publish(
self.device_manager.connector.set_and_publish(
MessageEndpoints.device_read("rt_omny"),
messages.DeviceMessage(
signals=signals, metadata={"point_id": point_id, **self.readout_metadata}
@@ -1068,7 +1091,7 @@ class RtOMNYMotor(Device, PositionerBase):
self.axis_Id = axis_Id
self.sign = sign
self.controller = RtOMNYController(
socket_cls=socket_cls, socket_host=host, socket_port=port
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
)
self.controller.set_axis(axis=self, axis_nr=self.axis_Id_numeric)
self.device_manager = device_manager
@@ -1096,6 +1119,9 @@ class RtOMNYMotor(Device, PositionerBase):
self.low_limit_travel.put(limits[0])
self.high_limit_travel.put(limits[1])
def wait_for_connection(self, all_signals=False, timeout: float = 30.0) -> bool:
self.controller.on(timeout=timeout)
@property
def limits(self):
return (self.low_limit_travel.get(), self.high_limit_travel.get())
@@ -1182,7 +1208,6 @@ class RtOMNYMotor(Device, PositionerBase):
return status
@property
def axis_Id(self):
return self._axis_Id_alpha
@@ -1227,7 +1252,7 @@ class RtOMNYMotor(Device, PositionerBase):
if __name__ == "__main__":
rtcontroller = RtOMNYController(
socket_cls=SocketIO, socket_host="mpc2844.psi.ch", socket_port=2222
socket_cls=SocketIO, socket_host="mpc2844.psi.ch", socket_port=2222, device_manager=None
)
rtcontroller.on()
rtcontroller.laser_tracker_on()

View File

@@ -0,0 +1,82 @@
import time
import socket
from ophyd import Component as Cpt
from ophyd import Device
from ophyd import EpicsSignal
class OMNYFastEpicsShutterError(Exception):
pass
def _detect_host_pv():
"""Detect host subnet and return appropriate PV name."""
try:
hostname = socket.gethostname()
local_ip = socket.gethostbyname(hostname)
if local_ip.startswith("129.129.122."):
return "X12SA-ES1-TTL:OUT_01"
else:
return "XOMNYI-XEYE-DUMMYSHUTTER:0"
except Exception as ex:
print(f"Warning: could not detect IP subnet ({ex}), using dummy shutter.")
return "XOMNYI-XEYE-DUMMYSHUTTER:0"
class OMNYFastEpicsShutter(Device):
"""
Fast EPICS shutter with automatic PV selection based on host subnet.
"""
USER_ACCESS = ["fshopen", "fshclose", "fshstatus", "fshinfo", "help"]
SUB_VALUE = "value"
_default_sub = SUB_VALUE
# PV is detected dynamically at import time
shutter = Cpt(EpicsSignal, name="shutter", read_pv=_detect_host_pv(), auto_monitor=True)
def __init__(self, prefix="", *, name, **kwargs):
super().__init__(prefix, name=name, **kwargs)
self.shutter.subscribe(self._emit_value)
def _emit_value(self, **kwargs):
timestamp = kwargs.pop("timestamp", time.time())
self.wait_for_connection()
self._run_subs(sub_type=self.SUB_VALUE, timestamp=timestamp, obj=self)
# -----------------------------------------------------
# User-facing shutter control functions
# -----------------------------------------------------
def fshopen(self):
"""Open the fast shutter."""
try:
self.shutter.put(1, wait=True)
except Exception as ex:
raise OMNYFastEpicsShutterError(f"Failed to open shutter: {ex}")
def fshclose(self):
"""Close the fast shutter."""
try:
self.shutter.put(0, wait=True)
except Exception as ex:
raise OMNYFastEpicsShutterError(f"Failed to close shutter: {ex}")
def fshstatus(self):
"""Return the fast shutter status (0=closed, 1=open)."""
try:
return self.shutter.get()
except Exception as ex:
raise OMNYFastEpicsShutterError(f"Failed to read shutter status: {ex}")
def fshinfo(self):
"""Print information about which EPICS PV channel is being used."""
pvname = self.shutter.pvname
print(f"Fast shutter connected to EPICS channel: {pvname}")
return pvname
def help(self):
"""Display available user methods."""
print("Available methods:")
for method in self.USER_ACCESS:
print(f" - {method}")

View File

@@ -0,0 +1,65 @@
import requests
import threading
import cv2
import numpy as np
from ophyd import Device, Component as Cpt
from ophyd_devices import PreviewSignal
import traceback
from bec_lib.logger import bec_logger
logger = bec_logger.logger
class WebcamViewer(Device):
USER_ACCESS = ["start_live_mode", "stop_live_mode"]
preview = Cpt(PreviewSignal, ndim=2, num_rotation_90=0, transpose=False)
def __init__(self, url:str, name:str, num_rotation_90=0, transpose=False, **kwargs) -> None:
super().__init__(name=name, **kwargs)
self.url = url
self._connection = None
self._update_thread = None
self._buffer = b""
self._shutdown_event = threading.Event()
self.preview.num_rotation_90 = num_rotation_90
self.preview.transpose = transpose
def start_live_mode(self) -> None:
if self._connection is not None:
return
self._update_thread = threading.Thread(target=self._update_loop, daemon=True)
self._update_thread.start()
def _update_loop(self) -> None:
while not self._shutdown_event.is_set():
try:
self._connection = requests.get(self.url, stream=True)
for chunk in self._connection.iter_content(chunk_size=1024):
self._buffer += chunk
start = self._buffer.find(b'\xff\xd8') # JPEG start
end = self._buffer.find(b'\xff\xd9') # JPEG end
if start == -1 or end == -1:
continue
jpg = self._buffer[start:end+2]
self._buffer = self._buffer[end+2:]
image = cv2.imdecode(np.frombuffer(jpg, np.uint8), cv2.IMREAD_COLOR)
if image is not None:
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
self.preview.put(image)
except Exception as exc:
content = traceback.format_exc()
logger.error(f"Image update loop failed: {content}")
def stop_live_mode(self) -> None:
if self._connection is None:
return
self._shutdown_event.set()
if self._connection is not None:
self._connection.close()
self._connection = None
if self._update_thread is not None:
self._update_thread.join()
self._update_thread = None
self._shutdown_event.clear()

View File

@@ -0,0 +1,74 @@
from ophyd import Component as Cpt
from ophyd import Device
from ophyd import DynamicDeviceComponent as Dcpt
from ophyd import EpicsSignal
class OMNYXRayEpicsGUI(Device):
save_frame = Cpt(
EpicsSignal, name="save_frame", read_pv="XOMNYI-XEYE-SAVFRAME:0",auto_monitor=True
)
update_frame_acqdone = Cpt(
EpicsSignal, name="update_frame_acqdone", read_pv="XOMNYI-XEYE-ACQDONE:0",auto_monitor=True
)
update_frame_acq = Cpt(
EpicsSignal, name="update_frame_acq", read_pv="XOMNYI-XEYE-ACQ:0",auto_monitor=True
)
width_y_dynamic = {
f"width_y_{i}": (EpicsSignal, f"XOMNYI-XEYE-YWIDTH_Y:{i}", {"auto_monitor": True}) for i in range(0, 11)
}
width_y = Dcpt(width_y_dynamic)
width_x_dynamic = {
f"width_x_{i}": (EpicsSignal, f"XOMNYI-XEYE-XWIDTH_X:{i}", {"auto_monitor": True}) for i in range(0, 11)
}
width_x = Dcpt(width_x_dynamic)
enable_mv_x = Cpt(
EpicsSignal, name="enable_mv_x", read_pv="XOMNYI-XEYE-ENAMVX:0",auto_monitor=True
)
enable_mv_y = Cpt(
EpicsSignal, name="enable_mv_y", read_pv="XOMNYI-XEYE-ENAMVY:0",auto_monitor=True
)
send_message = Cpt(
EpicsSignal, name="send_message", read_pv="XOMNYI-XEYE-MESSAGE:0.DESC",auto_monitor=True
)
sample_name = Cpt(
EpicsSignal, name="sample_name", read_pv="XOMNYI-XEYE-SAMPLENAME:0.DESC",auto_monitor=True
)
angle = Cpt(
EpicsSignal, name="angle", read_pv="XOMNYI-XEYE-ANGLE:0",auto_monitor=True
)
pixel_size = Cpt(
EpicsSignal, name="pixel_size", read_pv="XOMNYI-XEYE-PIXELSIZE:0",auto_monitor=True
)
submit = Cpt(
EpicsSignal, name="submit", read_pv="XOMNYI-XEYE-SUBMIT:0",auto_monitor=True
)
step = Cpt(
EpicsSignal, name="step", read_pv="XOMNYI-XEYE-STEP:0",auto_monitor=True
)
xval_x_dynamic = {
f"xval_x_{i}": (EpicsSignal, f"XOMNYI-XEYE-XVAL_X:{i}", {"auto_monitor": True}) for i in range(0, 11)
}
xval_x = Dcpt(xval_x_dynamic)
yval_y_dynamic = {
f"yval_y_{i}": (EpicsSignal, f"XOMNYI-XEYE-YVAL_Y:{i}", {"auto_monitor": True}) for i in range(0, 11)
}
yval_y = Dcpt(yval_y_dynamic)
recbg = Cpt(
EpicsSignal, name="recbg", read_pv="XOMNYI-XEYE-RECBG:0",auto_monitor=True
)
stage_pos_x_dynamic = {
f"stage_pos_x_{i}": (EpicsSignal, f"XOMNYI-XEYE-STAGEPOSX:{i}", {"auto_monitor": True}) for i in range(1, 6)
}
stage_pos_x = Dcpt(stage_pos_x_dynamic)
mvx = Cpt(
EpicsSignal, name="mvx", read_pv="XOMNYI-XEYE-MVX:0",auto_monitor=True
)
mvy = Cpt(
EpicsSignal, name="mvy", read_pv="XOMNYI-XEYE-MVY:0",auto_monitor=True
)

View File

@@ -93,6 +93,7 @@ class SmaractController(Controller):
socket_cls=None,
socket_host=None,
socket_port=None,
device_manager=None,
attr_name="",
labels=None,
):
@@ -102,6 +103,7 @@ class SmaractController(Controller):
socket_cls=socket_cls,
socket_host=socket_host,
socket_port=socket_port,
device_manager=device_manager,
attr_name=attr_name,
parent=parent,
labels=labels,

View File

@@ -123,10 +123,11 @@ class SmaractMotor(Device, PositionerBase):
limits=None,
sign=1,
socket_cls=SocketIO,
device_manager=None,
**kwargs,
):
self.controller = SmaractController(
socket_cls=socket_cls, socket_host=host, socket_port=port
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
)
self.axis_Id = axis_Id
self.sign = sign
@@ -152,6 +153,9 @@ class SmaractMotor(Device, PositionerBase):
self.low_limit_travel.put(limits[0])
self.high_limit_travel.put(limits[1])
def wait_for_connection(self, all_signals=False, timeout: float = 30.0) -> bool:
self.controller.on(timeout=timeout)
@property
def limits(self):
return (self.low_limit_travel.get(), self.high_limit_travel.get())

View File

@@ -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 csaxs_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

View File

View File

@@ -33,11 +33,11 @@ logger = bec_logger.logger
class FlomniFermatScan(SyncFlyScanBase):
scan_name = "flomni_fermat_scan"
scan_report_hint = "table"
scan_type = "fly"
required_kwargs = ["fovx", "fovy", "exp_time", "step", "angle"]
arg_input = {}
arg_bundle_size = {"bundle": len(arg_input), "min": None, "max": None}
use_scan_progress_report = True
def __init__(
self,
@@ -74,6 +74,7 @@ class FlomniFermatScan(SyncFlyScanBase):
"""
super().__init__(parameter=parameter, **kwargs)
self.show_live_table = False
self.axis = []
self.fovx = fovx
self.fovy = fovy
@@ -116,13 +117,12 @@ class FlomniFermatScan(SyncFlyScanBase):
shorten the movement time. In order to keep the last state, even if the
server is restarted, the state is stored in a global variable in redis.
"""
producer = self.device_manager.producer
msg = producer.get(MessageEndpoints.global_vars("reverse_flomni_trajectory"))
msg = self.connector.get(MessageEndpoints.global_vars("reverse_flomni_trajectory"))
if msg:
val = msg.content.get("value", False)
else:
val = False
producer.set(
self.connector.set(
MessageEndpoints.global_vars("reverse_flomni_trajectory"),
messages.VariableMessage(value=(not val)),
)
@@ -169,11 +169,12 @@ class FlomniFermatScan(SyncFlyScanBase):
tracker_signal_status = yield from self.stubs.send_rpc_and_wait(
"rtx", "controller.laser_tracker_check_signalstrength"
)
#self.device_manager.connector.send_client_info(tracker_signal_status)
if tracker_signal_status == "low":
self.device_manager.connector.raise_alarm(
severity=0,
alarm_type="LaserTrackerSignalStrength",
source="rtx",
source={"device": "rtx", "reason": "low signal strength", "method": "_prepare_setup_part2"},
metadata={},
msg="Signal strength of the laser tracker is low, sufficient to continue. Realignment recommended!",
)
@@ -280,7 +281,7 @@ class FlomniFermatScan(SyncFlyScanBase):
yield from self.stubs.kickoff(device="rtx")
while True:
yield from self.stubs.read(group="monitored")
status = self.device_manager.producer.get(MessageEndpoints.device_status("rt_scan"))
status = self.connector.get(MessageEndpoints.device_status("rt_scan"))
if status:
status_id = status.content.get("status", 1)
request_id = status.metadata.get("RID")
@@ -296,9 +297,10 @@ class FlomniFermatScan(SyncFlyScanBase):
logger.debug("reading monitors")
# yield from self.device_rpc("rtx", "controller.kickoff")
def return_to_start(self):
def move_to_start(self):
"""return to the start position"""
# in flomni, we need to move to the start position of the next scan
# in flomni, we need to move to the start position of the next scan, which is the end position of the current scan
# this method is called in finalize and overwrites the default move_to_start()
if isinstance(self.positions, np.ndarray) and len(self.positions[-1]) == 3:
yield from self.stubs.set(device=["rtx", "rty", "rtz"], value=self.positions[-1])
return

View File

@@ -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

View File

@@ -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.

View File

@@ -116,13 +116,12 @@ class OMNYFermatScan(SyncFlyScanBase):
shorten the movement time. In order to keep the last state, even if the
server is restarted, the state is stored in a global variable in redis.
"""
producer = self.device_manager.producer
msg = producer.get(MessageEndpoints.global_vars("reverse_omny_trajectory"))
msg = self.connector.get(MessageEndpoints.global_vars("reverse_omny_trajectory"))
if msg:
val = msg.content.get("value", False)
else:
val = False
producer.set(
self.connector.set(
MessageEndpoints.global_vars("reverse_omny_trajectory"),
messages.VariableMessage(value=(not val)),
)
@@ -265,7 +264,7 @@ class OMNYFermatScan(SyncFlyScanBase):
yield from self.stubs.kickoff(device="rtx")
while True:
yield from self.stubs.read(group="monitored")
status = self.device_manager.producer.get(MessageEndpoints.device_status("rt_scan"))
status = self.connector.get(MessageEndpoints.device_status("rt_scan"))
if status:
status_id = status.content.get("status", 1)
request_id = status.metadata.get("RID")
@@ -281,9 +280,10 @@ class OMNYFermatScan(SyncFlyScanBase):
logger.debug("reading monitors")
# yield from self.device_rpc("rtx", "controller.kickoff")
def return_to_start(self):
def move_to_start(self):
"""return to the start position"""
# in omny, we need to move to the start position of the next scan
# in omny, we need to move to the start position of the next scan, which is the end position of the current scan
# this method is called in finalize and overwrites the default move_to_start()
if isinstance(self.positions, np.ndarray) and len(self.positions[-1]) == 3:
yield from self.stubs.set(device=["rtx", "rty", "rtz"], value=self.positions[-1])
return

View File

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