Refactor/mcs card refactoring first light #87

Merged
appel_c merged 23 commits from refactor/mcs_card_refactoring_first_light into main 2025-08-07 10:10:02 +02:00
24 changed files with 2613 additions and 1214 deletions

View File

@@ -20,13 +20,13 @@ def extend_command_line_args(parser):
return parser
def get_config() -> ServiceConfig:
"""
Create and return the ServiceConfig for the plugin repository
"""
deployment_path = os.path.dirname(os.path.dirname(os.path.dirname(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})
# def get_config() -> ServiceConfig:
# """
# Create and return the ServiceConfig for the plugin repository
# """
# 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

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

View File

@@ -20,26 +20,25 @@ ddg2:
readoutPriority: baseline
softwareTrigger: false
samx:
readoutPriority: baseline
deviceClass: ophyd_devices.SimPositioner
mcs:
description: Mcs scalar card for transmission readout
deviceClass: csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs.MCSCardCSAXS
deviceConfig:
delay: 1
limits:
- -50
- 50
tolerance: 0.01
update_frequency: 400
deviceTags:
- user motors
prefix: 'X12SA-MCS:'
onFailure: raise
enabled: true
readOnly: false
bpm4i:
readoutPriority: monitored
deviceClass: ophyd_devices.SimMonitor
softwareTrigger: false
ids_cam:
description: IDS camera for live image acquisition
deviceClass: csaxs_bec.devices.ids_cameras.IDSCamera
deviceConfig:
deviceTags:
- beamline
camera_id: 201
bits_per_pixel: 24
m_n_colormode: 1
live_mode: True
onFailure: raise
enabled: true
readOnly: false
readoutPriority: async
softwareTrigger: True

View File

@@ -2,4 +2,7 @@ optics:
- !include ./optics_hutch.yaml
frontend:
- !include ./frontend.yaml
- !include ./frontend.yaml
endstation:
- !include ./endstation.yaml

View File

@@ -6,7 +6,7 @@ idgap:
onFailure: raise # Consider changing to buffer
enabled: true
readoutPriority: baseline
readOnly: true # put to false if you like to move it
readOnly: false # put to false if you like to move it
softwareTrigger: false
xbpm1x:
@@ -41,7 +41,7 @@ sl1xr:
description: 'slit 1 (frontend) x ring'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-SLDI1:TRXR'
prefix: 'X12SA-FE-SL1:TRXR'
onFailure: raise
enabled: true
readoutPriority: baseline
@@ -55,7 +55,7 @@ sl1xw:
description: 'slit 1 (frontend) x wall'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-SLDI1:TRXW'
prefix: 'X12SA-FE-SL1:TRXW'
onFailure: raise
enabled: true
readoutPriority: baseline
@@ -69,7 +69,7 @@ sl1yb:
description: 'slit 1 (frontend) y bottom'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-SLDI1:TRYB'
prefix: 'X12SA-FE-SL1:TRYB'
onFailure: raise
enabled: true
readoutPriority: baseline
@@ -83,7 +83,7 @@ sl1yt:
description: 'slit 1 (frontend) y top'
deviceClass: ophyd.EpicsMotor
deviceConfig:
prefix: 'X12SA-FE-SLDI1:TRYT'
prefix: 'X12SA-FE-SL1:TRYT'
onFailure: raise
enabled: true
readoutPriority: baseline

View File

@@ -79,6 +79,8 @@ xbpm2x:
- 200
port: 5000
sign: 1
# precision: 3
# tolerance: 0.005
enabled: true
onFailure: buffer
readOnly: false
@@ -95,6 +97,8 @@ xbpm2y:
- 200
port: 5000
sign: 1
# precision: 3
# tolerance: 0.005
enabled: true
onFailure: buffer
readOnly: false
@@ -111,6 +115,8 @@ cu_foilx:
- 200
port: 5000
sign: 1
# precision: 3
# tolerance: 0.005
enabled: true
onFailure: buffer
readOnly: false
@@ -127,6 +133,8 @@ scinx:
- 200
port: 5000
sign: 1
# precision: 3
# tolerance: 0.005
enabled: true
onFailure: buffer
readOnly: false

View File

@@ -29,21 +29,35 @@ DELAY CHANNELS:
- f = e + 1us (short pulse to OR gate for MCS triggering)
"""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from ophyd import DeviceStatus, StatusBase
from ophyd import DeviceStatus
from ophyd_devices import CompareStatus, 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, READYTOREAD
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
@@ -64,7 +78,7 @@ DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = {
DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.SINGLE_SHOT
DEFAULT_READOUT_TIMES = {"ab": 2e-4, "cd": 2e-4, "ef": 2e-4, "gh": 2e-4} # 0.2 ms 5kHz
DEFAULT_REFERENCES: list[tuple[AllChannelNames, CHANNELREFERENCE]] = [
DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [
("A", CHANNELREFERENCE.T0), # T0 + 2ms delay
("B", CHANNELREFERENCE.A),
("C", CHANNELREFERENCE.T0), # T0
@@ -78,12 +92,34 @@ DEFAULT_REFERENCES: list[tuple[AllChannelNames, CHANNELREFERENCE]] = [
class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
"""
Implementation of DelayGeneratorCSAXS for the CSAXS master trigger delay generator at X12SA-CPCL-DDG1.
It will be triggered by a soft trigger from BEC or a hardware trigger from a beamline device (e.g. the Galil stages).
It is operated in standard mode, not burst mode and will trigger the EXT/EN of DDG2 (channel ab).
It is responsible for opening the shutter (channel cd) and sending an extra trigger to an or gate for the MCS card (channel ef).
Implementation of DelayGeneratorCSAXS for master trigger delay generator at X12SA-CPCL-DDG1.
It will be triggered by a soft trigger from BEC or a hardware trigger from a beamline device
(e.g. the Galil stages). It is operated in standard mode, not burst mode and will trigger the
EXT/EN of DDG2 (channel ab). It is responsible for opening the shutter (channel cd) and sending
an extra trigger to an or gate for the MCS card (channel ef).
"""
def __init__(
self,
name: str,
prefix: str = "",
scan_info: ScanInfo | None = None,
device_manager: DeviceManagerBase | None = None,
**kwargs,
):
"""
Initialize the MCSCardCSAXS with the given arguments and keyword arguments.
"""
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:
"""
@@ -96,8 +132,10 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
self.set_io_values(channel, **config)
self.set_trigger(DEFAULT_TRIGGER_SOURCE)
self.set_references_for_channels(DEFAULT_REFERENCES)
# Set proc status to passively update with 5Hz (0.2s)
self.state.proc_status_mode.put(PROC_EVENT_MODE.EVENT)
def on_stage(self) -> DeviceStatus | StatusBase | None:
def on_stage(self) -> None:
"""
Stage logic for the DDG1 device, being th main trigger delay generator for CSAXS.
For standard scans, it will be triggered by a soft trigger from BEC.
@@ -105,7 +143,8 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
This DDG is always not in burst mode.
"""
self.burst_disable()
exp_time = self.scan_info.msg.scan_parameters["exp_time"]
self.burst_enable(1, 0, exp_time)
exp_time = self.scan_info.msg.scan_parameters["exp_time"]
frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"]
# Trigger DDG2
@@ -122,45 +161,124 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
# e has refernce to d, f has reference to e
self.set_delay_pairs(channel="ef", delay=0, width=1e-6)
def on_trigger(self) -> DeviceStatus | StatusBase | None:
def _prepare_mcs_on_trigger(self, mcs: MCSCardCSAXS) -> None:
"""Prepare the MCS card for the next trigger.
This method holds the logic to ensure that the MCS card is ready to read.
It's logic is coupled to the MCS card implementation and the DDG1 trigger logic.
"""
status_ready_read = CompareStatus(mcs.ready_to_read, READYTOREAD.DONE)
mcs.stop_all.put(1)
status_acquiring = TransitionStatus(mcs.acquiring, [ACQUIRING.DONE, ACQUIRING.ACQUIRING])
self.cancel_on_stop(status_ready_read)
self.cancel_on_stop(status_acquiring)
status_ready_read.wait(10)
mcs.ready_to_read.put(READYTOREAD.PROCESSING)
mcs.erase_start.put(1)
status_acquiring.wait(timeout=10) # Allow 10 seconds in case communication is slow
def _poll_event_status(self) -> None:
"""
Poll the event status register in a background thread. Control
the polling with the _poll_thread_run_event and _poll_thread_kill_event.
"""
while not self._poll_thread_kill_event.is_set():
self._poll_thread_run_event.wait()
self._poll_thread_poll_loop_done.clear()
while (
self._poll_thread_run_event.is_set() and not self._poll_thread_kill_event.is_set()
):
self._poll_loop()
self._poll_thread_poll_loop_done.set()
def _poll_loop(self) -> None:
"""
Poll loop to update event status.
The checks ensure that the loop exist after each operation and be stuck in sleep.
The 20ms sleep was added to ensure that the event status is not polled too frequently,
and to give the device time to process the previous command. This was found empirically
to be necessary to avoid missing events.
"""
self.state.proc_status.put(1, use_complete=True)
time.sleep(0.02) # 20ms delay for processing, important for not missing events
if self._poll_thread_run_event.is_set() and not self._poll_thread_kill_event.is_set():
return
self.state.event_status.get(use_monitor=False)
if self._poll_thread_run_event.is_set() and not self._poll_thread_kill_event.is_set():
return
time.sleep(0.02) # 20ms delay for processing, important for not missing events
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) -> DeviceStatus:
"""Prepare the trigger status event for the DDG1, and trigger the de"""
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."""
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:
"""Note, we need to add a delay to the StatusBits callback on the event_status.
If we don't then subsequent triggers may reach the DDG too early, and will be ignored. To
avoid this, we've added the option to specify a delay via add_delay, default here is 50ms.
"""
st = StatusBase()
self.cancel_on_stop(st)
# Stop polling, poll once manually to ensure that the register is clean
self._stop_polling()
self._poll_thread_poll_loop_done.wait(timeout=1)
# Prepare the MCS card for the next software trigger
mcs = self.device_manager.devices.get("mcs", None)
if mcs is None:
logger.info("Did not find mcs card with name 'mcs' in current session")
else:
self._prepare_mcs_on_trigger(mcs)
# Prepare status with callback to cancel the polling once finished
status = self._prepare_trigger_status_event()
# Start polling
self._start_polling()
# Trigger the DDG1
self.trigger_shot.put(1, use_complete=True)
time.sleep(self.scan_info.msg.scan_parameters["exp_time"])
self.cancel_on_stop(st)
status = self.wait_for_status(status=st, bit_event=STATUSBITS.END_OF_DELAY, timeout=2)
return status
def wait_for_status(
self, status: StatusBase, bit_event: STATUSBITS, timeout: float = 2
) -> None:
"""Wait for a event status bit to be set.
Args:
status (StatusBase): The status object to update.
bit_event (STATUSBITS): The event status bit to wait for.
timeout (float): Maximum time to wait for the event status bit to be set.
"""
current_time = time.time()
while not status.done:
self.state.proc_status.put(1, use_complete=True)
event_status = self.state.event_status.get()
if (STATUSBITS(event_status) & bit_event) == bit_event:
status.set_finished()
if time.time() - current_time > timeout:
status.set_exception(TimeoutError(f"Timeout waiting for status {status}"))
break
time.sleep(0.1)
time.sleep(0.05) # Give time for the IOC to be ready again
return status
def on_stop(self) -> None:
"""Stop the delay generator by setting the burst mode to 0"""
self.stop_ddg()
self._stop_polling()
def on_destroy(self) -> None:
"""Clean up resources when the device is destroyed."""
self._kill_poll_thread()
if __name__ == "__main__":

View File

@@ -36,6 +36,7 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import
AllChannelNames,
ChannelConfig,
DelayGeneratorCSAXS,
LiteralChannels,
)
logger = bec_logger.logger
@@ -57,7 +58,7 @@ DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = {
DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.EXT_RISING_EDGE
DEFAULT_READOUT_TIMES = {"ab": 2e-4, "cd": 2e-4, "ef": 2e-4, "gh": 2e-4} # 0.2 ms 5kHz
DEFAULT_REFERENCES: list[tuple[AllChannelNames, CHANNELREFERENCE]] = [
DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [
("A", CHANNELREFERENCE.T0),
("B", CHANNELREFERENCE.A),
("C", CHANNELREFERENCE.T0),
@@ -100,16 +101,20 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS):
frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"]
# a = t0
# a has reference to t0, b has reference to a
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}"
)
burst_pulse_width = exp_time - DEFAULT_READOUT_TIMES["ab"]
self.set_delay_pairs(channel="ab", delay=0, width=burst_pulse_width)
self.burst_enable(count=frames_per_trigger, delay=0, period=exp_time)
def on_pre_scan(self):
"""
The delay generator occasionally needs a bit extra time to process all
commands from stage. Therefore, we introduce here a short sleep
"""
# Delay Generator occasionaly needs a bit extra time to process all commands, sleep 50ms
# Delay Generator occasionaly needs a bit extra time to process all commands, sleep 50ms
time.sleep(0.05)
def on_trigger(self) -> DeviceStatus | StatusBase | None:
@@ -118,7 +123,7 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS):
"""
def wait_for_status(
self, status: StatusBase, bit_event: STATUSBITS, timeout: float = 2
self, status: DeviceStatus, bit_event: STATUSBITS, timeout: float = 5
) -> None:
"""Wait for a event status bit to be set.
@@ -134,7 +139,11 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS):
if (STATUSBITS(event_status) & bit_event) == bit_event:
status.set_finished()
if time.time() - current_time > timeout:
status.set_exception(TimeoutError(f"Timeout waiting for status {status}"))
status.set_exception(
TimeoutError(
f"Timeout waiting for status of device {self.name} for event_status {bit_event}"
)
)
break
time.sleep(0.1)
time.sleep(0.05) # Give time for the IOC to be ready again

View File

@@ -6,8 +6,8 @@ https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf
"""
import enum
from typing import Literal, TypedDict
import time
from typing import Literal, TypedDict
from bec_lib.logger import bec_logger
from ophyd import Component as Cpt
@@ -76,9 +76,26 @@ class OUTPUTPOLARITY(enum.Enum):
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.
@@ -91,6 +108,7 @@ class STATUSBITS(enum.IntFlag):
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.",
@@ -114,7 +132,7 @@ class StatusBitsCompareStatus(SubscriptionStatus):
*args,
event_type=None,
timeout: float | None = None,
add_delay:float|None = None,
add_delay: float | None = None,
settle_time: float = 0,
run: bool = True,
**kwargs,
@@ -137,9 +155,9 @@ class StatusBitsCompareStatus(SubscriptionStatus):
"""Callback for subscription status"""
obj = kwargs.get("obj", None)
if obj is None:
name = 'no object received'
name = "no object received"
else:
name=obj.name
name = obj.name
if any((STATUSBITS(value) & state) == state for state in self._raise_states):
self.set_exception(
ValueError(
@@ -147,7 +165,7 @@ class StatusBitsCompareStatus(SubscriptionStatus):
)
)
return False
if self._add_delay !=0:
if self._add_delay != 0:
time.sleep(self._add_delay)
return (STATUSBITS(value) & self._value) == self._value
@@ -378,7 +396,6 @@ class DelayGeneratorEventStatus(Device):
"EventStatusLI",
name="event_status",
kind=Kind.omitted,
auto_monitor=True,
doc="Event status register for the delay generator",
)
proc_status = Cpt(
@@ -389,6 +406,13 @@ class DelayGeneratorEventStatus(Device):
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):
"""
@@ -403,7 +427,14 @@ class DelayGeneratorCSAXS(Device):
In addition, the io layer allows setting amplitude, offset and polarity for each pair.
"""
_pv_timeout: float = 1.5 # Default timeout for PV operations in seconds
# 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")
@@ -686,11 +717,23 @@ class DelayGeneratorCSAXS(Device):
}[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)

View File

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

View File

@@ -0,0 +1,341 @@
"""
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.",
)
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.",
)
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,
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.",
)
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.",
)
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,284 @@
"""Module for the MCSCard CSAXS implementation."""
from __future__ import annotations
import enum
from threading import RLock
from typing import TYPE_CHECKING
import numpy as np
from bec_lib.logger import bec_logger
from ophyd import Component as Cpt
from ophyd import Device, EpicsSignalRO, Kind, Signal
from ophyd_devices import CompareStatus, ProgressSignal, TransitionStatus
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,
)
from csaxs_bec.devices.epics.xbpms import DiffXYSignal, SumSignal
if TYPE_CHECKING: # pragma: no cover
from bec_lib.devicemanager import DeviceManagerBase, ScanInfo
logger = bec_logger.logger
class READYTOREAD(int, enum.Enum):
PROCESSING = 0
DONE = 1
class BPMDevice(Device):
"""Class for BPM device of the MCSCard."""
current1 = Cpt(Signal, kind=Kind.normal, doc="Normalized current 1")
current2 = Cpt(Signal, kind=Kind.normal, doc="Normalized current 2")
current3 = Cpt(Signal, kind=Kind.normal, doc="Normalized current 3")
current4 = Cpt(Signal, kind=Kind.normal, doc="Normalized current 4")
count_time = Cpt(Signal, kind=Kind.normal, doc="Count time for bpm signal counts")
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",
)
class MCSRaw(Device):
"""Class for BPM device of the MCSCard with normalized currents."""
mca1 = Cpt(Signal, kind=Kind.normal, doc="Raw counts on mca1 channel")
mca2 = Cpt(Signal, kind=Kind.normal, doc="Raw counts on mca2 channel")
mca3 = Cpt(Signal, kind=Kind.normal, doc="Raw counts on mca3 channel")
mca4 = Cpt(Signal, kind=Kind.normal, doc="Raw counts on mca4 channel")
mca5 = Cpt(Signal, kind=Kind.normal, doc="Raw counts on mca5 channel")
class MCSCardCSAXS(PSIDeviceBase, MCSCard):
"""
Implementation of the MCSCard SIS3820 for CSAXS, prefix 'X12SA-MCS:'.
The basic functionality is inherited from the MCSCard class.
"""
ready_to_read = Cpt(
Signal,
kind=Kind.omitted,
doc="Signal that indicates if mcs card is ready to be read from after triggers. 0 not ready, 1 ready",
)
progress: ProgressSignal = Cpt(ProgressSignal, name="progress")
# Make this an async signal..
mcs = Cpt(
MCSRaw,
name="mcs",
kind=Kind.normal,
doc="MCS device with raw current and count time readings",
)
bpm = Cpt(
BPMDevice,
name="bpm",
kind=Kind.normal,
doc="BPM device for MCSCard with count times and normalized currents",
)
def __init__(
self,
name: str,
prefix: str = "",
scan_info: ScanInfo | None = None,
device_manager: DeviceManagerBase | None = None,
**kwargs,
):
"""
Initialize the MCSCardCSAXS with the given arguments and keyword arguments.
"""
super().__init__(
name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs
)
self._mcs_clock = 1e7 # 10MHz clock -> 1e7 Hz
self._pv_timeout = 3 # TODO remove timeout once #129 in ophyd_devices is solved
self._rlock = RLock() # Needed to ensure thread safety for counter updates
self.counter_mapping = { # Any mca counter that should be updated has to be added here
f"{self.counters.name}_mca1": "current1",
f"{self.counters.name}_mca2": "current2",
f"{self.counters.name}_mca3": "current3",
f"{self.counters.name}_mca4": "current4",
f"{self.counters.name}_mca5": "count_time",
}
self.counter_updated = []
def on_connected(self):
"""
Called when the device is connected.
"""
# Make sure card is not running
self.stop_all.put(1)
# TODO Check channel1_source !!
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)
# Set the user LED to off
self.user_led.set(0).wait(timeout=self._pv_timeout)
# Only channel 1-5 are connected so far, adjust if more are needed
self.mux_output.set(5).wait(timeout=self._pv_timeout)
# 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)
# Set appropriate read mode
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)
# Subscribe to the mca updates
for name in self.counter_mapping.keys():
sig: EpicsSignalRO = getattr(self.counters, name.split("_")[-1])
sig.subscribe(self._on_counter_update, run=False)
def _on_counter_update(self, value, **kwargs) -> None:
"""
Callback for counter updates of the mca channels (1-32).
The raw data is pushed to the mcs sub-device (MCSRaw). We need to ensure that
the MCSRaw device has all signals defined for which we want to push the values.
As we may receive multiple readings per point, e.g. if frames_per_trigger > 1,
we also create a mean value for the counter signals. These are then pushed to the bpm device
for plotting and further processing. The signal names are defined and mapped in the
self.counter_mapping dictionary & the bpm sub-device.
There are multiple mca channels, each giving individual updates. We want to ensure that
each is updated before we signal that we are ready to read. In future, these signals may
become asynchronous, but we first need to ensure that we can properly combine monitored
signals with async signals for plotting. Until then, we will keep this logic.
"""
with self._rlock:
# Retrieve the signal object which executes this callback
signal = kwargs.get("obj", None)
if signal is None: # This should never happen, but just in case
logger.info(f"Called without 'obj' in kwargs: {kwargs}")
return
# Get the maped signal name from the mapping dictionary
mapped_signal_name = self.counter_mapping.get(signal.name, None)
# If we did not map the signal name in counter_mapping, but receive an update
# we will skip it.
if mapped_signal_name is None:
return
# Push the raw values of the mca channels. The signal name has to be defined
# in the self.mcs sub-device (MCSRaw) to be able to push the values. Otherwise
# we will skip the update.
mca_raw = getattr(self.mcs, signal.name.split("_")[-1], None)
if mca_raw is None:
return
# In case there was more than one value received, i.e. frames_per_trigger > 1,
# we will receive a np.array of values.
if isinstance(value, np.ndarray):
# We push the raw values as a list to the mca_raw signal
# And otherwise compute the mean value for plotting of counter signals
mca_raw.put(value.tolist())
# compute the count_time in seconds
if mapped_signal_name == "count_time":
value = value / self._mcs_clock
value = float(value.mean())
else:
# We received a single value, so we can directly push it
mca_raw.put(value)
# compute the count_time in seconds
if mapped_signal_name == "count_time":
value = value / self._mcs_clock
# Get the mapped signal from the bpm device and update it
sig = getattr(self.bpm, mapped_signal_name)
sig.put(value)
self.counter_updated.append(signal.name)
# Once all mca channels have been updated, we can signal that we are ready to read
received_all_updates = set(self.counter_updated) == set(self.counter_mapping.keys())
if received_all_updates:
self.ready_to_read.put(READYTOREAD.DONE)
# The reset of the signal is done in the on_trigger method of ddg1 for the next trigger
self.counter_updated.clear() # Clear the list for the next update cycle
def _progress_update(self, value, **kwargs) -> None:
"""Callback for progress updates from ophyd subscription on current_channel."""
# This logic needs to be further refined as this is currently reporting the progress
# of a single trigger from BEC within a burst scan.
frames_per_trigger = self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1)
self.progress.put(
value=value, max_value=frames_per_trigger, done=bool(value == frames_per_trigger)
)
def on_stage(self) -> None:
"""
Called when the device is staged.
"""
self.erase_all.set(1).wait(timeout=self._pv_timeout)
triggers = self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1)
self.preset_real.set(0).wait(timeout=self._pv_timeout)
self.num_use_all.set(triggers).wait(timeout=self._pv_timeout)
def on_unstage(self) -> None:
"""
Called when the device is unstaged.
"""
self.stop_all.put(1)
self.ready_to_read.put(READYTOREAD.DONE)
# TODO why 0?
self.erase_all.set(0).wait(timeout=self._pv_timeout)
def on_trigger(self) -> None:
status = TransitionStatus(
self.ready_to_read, strict=True, transitions=[READYTOREAD.PROCESSING, READYTOREAD.DONE]
)
self.cancel_on_stop(status)
return status
def on_pre_scan(self) -> None:
"""
Called before the scan starts.
"""
def on_complete(self) -> CompareStatus:
"""On scan completion."""
# Check if we should get a signal based on updates from the MCA channels
status = CompareStatus(self.acquiring, ACQUIRING.DONE)
self.cancel_on_stop(status)
return status
def on_stop(self) -> None:
"""
Called when the scan is stopped.
"""
self.stop_all.put(1)
self.ready_to_read.put(READYTOREAD.DONE)
# Reset the progress signal
# self.progress.put(0, done=True)

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,4 +1,5 @@
import time
from ophyd import Component as Cpt
from ophyd import Device, EpicsSignalRO, Signal
@@ -22,10 +23,10 @@ class SumSignal(Signal):
def describe(self):
source = [
self.parent.current1.pvname,
self.parent.current2.pvname,
self.parent.current3.pvname,
self.parent.current4.pvname,
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 = {
@@ -33,7 +34,9 @@ class SumSignal(Signal):
"dtype": "number",
"source": f"PV: {source}",
"units": "",
"precision": self.parent.current1.precision,
"precision": (
self.parent.current1.precision if hasattr(self.parent.current1, "precision") else 0
),
}
return desc
@@ -64,23 +67,36 @@ class DiffXYSignal(Signal):
return (summed_1 - summed_2) / _sum
def describe(self):
source = [getattr(self.parent, signal).pvname for signal in self.sum1 + self.sum2]
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,
"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)
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,

View File

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

View File

@@ -0,0 +1,274 @@
"""
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
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,
):
self.ueye = ueye
self.camera_id = camera_id
self._inputs = {"m_n_colormode": m_n_colormode, "bits_per_pixel": bits_per_pixel}
self._connected = False
self.cam = None
atexit.register(self.on_disconnect)
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):
"""Disconnect from the camera."""
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
logger.info("Camera disconnected.")
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:
logger.warning("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
return np.reshape(
array, (self.cam.height.value, self.cam.width.value, self.cam.bytes_per_pixel)
)
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

@@ -2,227 +2,99 @@ import threading
import time
import numpy as np
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 import DeviceStatus, Kind, Signal, StatusBase
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from ophyd_devices.utils.bec_signals import PreviewSignal
try:
from pyueye import ueye
except ImportError:
# The pyueye library is not installed or doesn't provide the necessary c libs
ueye = None
logger = bec_logger.logger
class IDSCustomPrepare(CustomDetectorMixin):
class ROISignal(Signal):
"""
Signal to handle the Region of Interest (ROI) for the IDS camera.
It is a tuple of (x, y, width, height).
"""
USER_ACCESS = ["pyueye"]
pyueye = ueye
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
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)
def __init__(
self,
*,
name,
roi: tuple | None = None,
value=0,
dtype=None,
shape=None,
timestamp=None,
parent=None,
labels=None,
kind=Kind.hinted,
tolerance=None,
rtolerance=None,
metadata=None,
cl=None,
attr_name="",
):
super().__init__(
name=name,
value=value,
dtype=dtype,
shape=shape,
timestamp=timestamp,
parent=parent,
labels=labels,
kind=kind,
tolerance=tolerance,
rtolerance=rtolerance,
metadata=metadata,
cl=cl,
attr_name=attr_name,
)
if nRet != self.ueye.IS_SUCCESS:
print("is_AOI ERROR")
self.roi = roi
self.width = self.rectAOI.s32Width
self.height = self.rectAOI.s32Height
def get(self, **kwargs):
image = self.parent.image_data.get().data
if not isinstance(image, np.ndarray):
return -1 # -1 if no valid image is available
# 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")
if self.roi is None:
roi = (0, 0, image.shape[1], image.shape[0])
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())
roi = self.roi
if len(image.shape) > 2:
image = np.sum(image, axis=2) # Convert to grayscale if it's a color image
return np.sum(image[roi[1] : roi[1] + roi[3], roi[0] : roi[0] + roi[2]], (0, 1))
class IDSCamera(PSIDetectorBase):
USER_ACCESS = ["start_live_mode", "stop_live_mode"]
class IDSCamera(PSIDeviceBase):
""" "
#---------------------------------------------------------------------------------------------------------------------------------------
custom_prepare_cls = IDSCustomPrepare
#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)
image_data = Cpt(SetableSignal, value=np.empty((100, 100)), kind=Kind.omitted)
ids_cam
...
"""
SUB_MONITOR = "device_monitor_2d"
_default_sub = SUB_MONITOR
USER_ACCESS = ["start_live_mode", "stop_live_mode", "set_roi", "width", "height"]
image_data = Cpt(PreviewSignal, ndim=2, kind=Kind.omitted)
# roi_bot_left = Cpt(ROISignal, roi=(400, 525, 118, 105), kind=Kind.normal)
# roi_bot_right = Cpt(ROISignal, roi=(518, 525, 118, 105), kind=Kind.normal)
# roi_top_left = Cpt(ROISignal, roi=(400, 630, 118, 105), kind=Kind.normal)
# roi_top_right = Cpt(ROISignal, roi=(518, 630, 118, 105), kind=Kind.normal)
# roi_signal = Cpt(ROISignal, kind=Kind.normal, doc="Region of Interest signal")
def __init__(
self,
@@ -234,19 +106,224 @@ class IDSCamera(PSIDetectorBase):
channels: int,
m_n_colormode: int,
kind=None,
parent=None,
device_manager=None,
**kwargs,
):
super().__init__(
prefix=prefix, name=name, kind=kind, parent=parent, device_manager=device_manager, **kwargs
prefix=prefix, name=name, kind=kind, device_manager=device_manager, **kwargs
)
self.camera_ID = camera_ID
self.bits_per_pixel = bits_per_pixel
self.bytes_per_pixel = None
self.channels = channels
self.m_n_colormode = m_n_colormode
#TODO fix connected and wait_for_connection
self.custom_prepare.on_connection_established()
self._m_n_colormode_input = m_n_colormode
self.m_n_colormode = None
self.ueye = ueye
self.h_cam = None
self.s_info = None
self.data_thread = None
self.c_info = None
self.pc_image_memory = None
self.mem_id = None
self.rect_aoi = None
self.pitch = None
self.n_bits_per_pixel = None
self.width = None
self.height = None
self.thread_event = threading.Event()
self.data_thread = None
self._roi: tuple | None = None # x, y, width, height
logger.info(
f"Deprecation warning: The IDSCamera class is deprecated. Use the new IDSCameraNew class instead."
)
def set_roi(self, x: int, y: int, width: int, height: int):
self._roi = (x, y, width, height)
def start_backend(self):
if self.ueye is None:
raise ImportError("The pyueye library is not installed.")
self.h_cam = self.ueye.HIDS(
self.camera_ID
) # 0: first available camera; 1-254: The camera with the specified camera ID
self.s_info = self.ueye.SENSORINFO()
self.c_info = self.ueye.CAMINFO()
self.pc_image_memory = self.ueye.c_mem_p()
self.mem_id = self.ueye.int()
self.rect_aoi = self.ueye.IS_RECT()
self.pitch = self.ueye.INT()
self.n_bits_per_pixel = self.ueye.INT(
self.bits_per_pixel
) # 24: bits per pixel for color mode; take 8 bits per pixel for monochrome
self.m_n_colormode = self.ueye.INT(
self._m_n_colormode_input
) # Y8/RGB16/RGB24/REG32 (1 for our color cameras)
self.bytes_per_pixel = int(self.n_bits_per_pixel / 8)
# Starts the driver and establishes the connection to the camera
ret = self.ueye.is_InitCamera(self.h_cam, None)
if ret != 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 c_info points to
ret = self.ueye.is_GetCameraInfo(self.h_cam, self.c_info)
if ret != self.ueye.IS_SUCCESS:
print("is_GetCameraInfo ERROR")
# You can query additional information about the sensor type used in the camera
ret = self.ueye.is_GetSensorInfo(self.h_cam, self.s_info)
if ret != self.ueye.IS_SUCCESS:
print("is_GetSensorInfo ERROR")
ret = self.ueye.is_ResetToDefault(self.h_cam)
if ret != self.ueye.IS_SUCCESS:
print("is_ResetToDefault ERROR")
# Set display mode to DIB
ret = self.ueye.is_SetDisplayMode(self.h_cam, self.ueye.IS_SET_DM_DIB)
# Set the right color mode
if (
int.from_bytes(self.s_info.nColorMode.value, byteorder="big")
== self.ueye.IS_COLORMODE_BAYER
):
# 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)
bytes_per_pixel = int(self.n_bits_per_pixel / 8)
print("IS_COLORMODE_BAYER: ")
print("\tm_n_colormode: \t\t", self.m_n_colormode)
print("\tn_bits_per_pixel: \t\t", self.n_bits_per_pixel)
print("\tbytes_per_pixel: \t\t", bytes_per_pixel)
print()
elif (
int.from_bytes(self.s_info.nColorMode.value, byteorder="big")
== self.ueye.IS_COLORMODE_CBYCRY
):
# for color camera models use RGB32 mode
m_n_colormode = self.ueye.IS_CM_BGRA8_PACKED
n_bits_per_pixel = self.ueye.INT(32)
bytes_per_pixel = int(self.n_bits_per_pixel / 8)
print("IS_COLORMODE_CBYCRY: ")
print("\tm_n_colormode: \t\t", m_n_colormode)
print("\tn_bits_per_pixel: \t\t", n_bits_per_pixel)
print("\tbytes_per_pixel: \t\t", bytes_per_pixel)
print()
elif (
int.from_bytes(self.s_info.nColorMode.value, byteorder="big")
== self.ueye.IS_COLORMODE_MONOCHROME
):
# for color camera models use RGB32 mode
m_n_colormode = self.ueye.IS_CM_MONO8
n_bits_per_pixel = self.ueye.INT(8)
bytes_per_pixel = int(n_bits_per_pixel / 8)
print("IS_COLORMODE_MONOCHROME: ")
print("\tm_n_colormode: \t\t", m_n_colormode)
print("\tn_bits_per_pixel: \t\t", n_bits_per_pixel)
print("\tbytes_per_pixel: \t\t", bytes_per_pixel)
print()
else:
# for monochrome camera models use Y8 mode
m_n_colormode = self.ueye.IS_CM_MONO8
n_bits_per_pixel = self.ueye.INT(8)
bytes_per_pixel = int(n_bits_per_pixel / 8)
print("else")
# Can be used to set the size and position of an "area of interest"(AOI) within an image
ret = self.ueye.is_AOI(
self.h_cam,
self.ueye.IS_AOI_IMAGE_GET_AOI,
self.rect_aoi,
self.ueye.sizeof(self.rect_aoi),
)
if ret != self.ueye.IS_SUCCESS:
print("is_AOI ERROR")
self.width = self.rect_aoi.s32Width
self.height = self.rect_aoi.s32Height
# Prints out some information about the camera and the sensor
print("Camera model:\t\t", self.s_info.strSensorName.decode("utf-8"))
print("Camera serial no.:\t", self.c_info.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 n_bits_per_pixel
ret = self.ueye.is_AllocImageMem(
self.h_cam,
self.width,
self.height,
self.n_bits_per_pixel,
self.pc_image_memory,
self.mem_id,
)
if ret != self.ueye.IS_SUCCESS:
print("is_AllocImageMem ERROR")
else:
# Makes the specified image memory the active memory
ret = self.ueye.is_SetImageMem(self.h_cam, self.pc_image_memory, self.mem_id)
if ret != self.ueye.IS_SUCCESS:
print("is_SetImageMem ERROR")
else:
# Set the desired color mode
ret = self.ueye.is_SetColorMode(self.h_cam, self.m_n_colormode)
# Activates the camera's live video mode (free run mode)
ret = self.ueye.is_CaptureVideo(self.h_cam, self.ueye.IS_DONT_WAIT)
if ret != self.ueye.IS_SUCCESS:
print("is_CaptureVideo ERROR")
# Enables the queue mode for existing image memory sequences
ret = self.ueye.is_InquireImageMem(
self.h_cam,
self.pc_image_memory,
self.mem_id,
self.width,
self.height,
self.n_bits_per_pixel,
self.pitch,
)
if ret != 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.start_live_mode()
def _start_data_thread(self):
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():
if self.ueye is None:
print("pyueye library not available.")
return
# In order to display the image in an OpenCV window we need to...
# ...extract the data of our image memory
array = self.ueye.get_data(
self.pc_image_memory,
self.width,
self.height,
self.n_bits_per_pixel,
self.pitch,
copy=False,
)
# ...reshape it in an numpy array...
frame = np.reshape(array, (self.height.value, self.width.value, self.bytes_per_pixel))
self.image_data.put(frame)
time.sleep(0.1)
def wait_for_connection(self, all_signals=False, timeout=10):
if ueye is None:
@@ -254,226 +331,73 @@ class IDSCamera(PSIDetectorBase):
"The pyueye library is not installed or doesn't provide the necessary c libs"
)
super().wait_for_connection(all_signals, timeout)
#self.custom_prepare.on_connection_established()
def destroy(self):
"""Extend Ophyds destroy function to kill the data thread"""
self.stop_live_mode()
super().destroy()
def start_live_mode(self):
if self.custom_prepare.data_thread is not None:
if self.data_thread is not None:
self.stop_live_mode()
self.custom_prepare._start_data_thread()
self._start_data_thread()
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
self.thread_event.set()
if self.data_thread is not None:
self.data_thread.join()
self.thread_event.clear()
self.data_thread = None
########################################
# Beamline Specific Implementations #
########################################
def on_init(self) -> None:
"""
Called when the device is initialized.
No signals are connected at this point. If you like to
set default values on signals, please use 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.
"""
self.start_backend()
self.start_live_mode()
def on_stage(self) -> DeviceStatus | StatusBase | None:
"""
Called while staging the device.
Information about the upcoming scan can be accessed from the scan_info (self.scan_info.msg) object.
"""
def on_unstage(self) -> DeviceStatus | StatusBase | None:
"""Called while unstaging the device."""
def on_pre_scan(self) -> DeviceStatus | StatusBase | None:
"""Called right before the scan starts on all devices automatically."""
def on_trigger(self) -> DeviceStatus | StatusBase | None:
"""Called when the device is triggered."""
def on_complete(self) -> DeviceStatus | StatusBase | None:
"""Called to inquire if a device has completed a scans."""
def on_kickoff(self) -> DeviceStatus | StatusBase | 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."""
def on_destroy(self) -> None:
"""Called when the device is destroyed. Cleanup resources here."""
self.stop_live_mode()
"""from pyueye import ueye
import numpy as np
import cv2
import sys
import time
if __name__ == "__main__":
# Example usage
camera = IDSCamera(name="camera", camera_ID=201, bits_per_pixel=24, channels=3, m_n_colormode=1)
camera.wait_for_connection()
#---------------------------------------------------------------------------------------------------------------------------------------
#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)
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)
# Press q if you want to end the loop
if (cv2.waitKey(1) & 0xFF) == ord('q'):
break
#---------------------------------------------------------------------------------------------------------------------------------------
# Releases an image memory that was allocated using is_AllocImageMem() and removes it from the driver management
ueye.is_FreeImageMem(hCam, pcImageMemory, MemID)
# Disables the hCam camera handle and releases the data structures and memory areas taken up by the uEye camera
ueye.is_ExitCamera(hCam)
# Destroys the OpenCv windows
cv2.destroyAllWindows()
print()
print("END")
"""
camera.on_destroy()

View File

@@ -0,0 +1,226 @@
"""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_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from ophyd_devices.utils.bec_signals import AsyncSignal, PreviewSignal
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 IDSCamera(PSIDeviceBase):
"""IDS Camera class for cSAXS.
This class inherits from PSIDeviceBase and implements the necessary methods
to interact with the IDS camera using the pyueye library.
"""
image = Cpt(PreviewSignal, name="image", ndim=2, doc="Preview signal for the camera.")
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]},
)
USER_ACCESS = ["live_mode", "mask", "set_rect_roi", "get_last_image"]
def __init__(
self,
*,
name: str,
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,
**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._live_mode = False
self._inputs = {"live_mode": live_mode}
self._mask = np.zeros((1, 1), dtype=np.uint8)
############## 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}."
)
self._mask = value
@property
def live_mode(self) -> bool:
"""Return whether the camera is in live mode."""
return self._live_mode
@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 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()
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:
self._live_mode_thread = None
logger.info("Live mode stopped.")
def _live_mode_loop(self, stop_event: threading.Event):
"""Loop to capture images in live mode."""
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
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)
def get_last_image(self) -> np.ndarray:
"""Get the last captured image from the camera."""
image = self.image.get()
if image:
return image.data
############## User Interface Methods ##############
def on_connected(self):
"""Connect to the camera."""
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(
{
self.roi_signal.name: {
"value": np.sum(data)
/ (np.sum(self.mask) * n_channels), # TODO could be optimized
"timestamp": time.time(),
}
}
)
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

@@ -191,7 +191,10 @@ def test_ddg1_stage(mock_ddg1):
mock_ddg1.stage()
assert np.isclose(mock_ddg1.burst_mode.get(), 0) # Burst mode is disabled
assert np.isclose(mock_ddg1.burst_mode.get(), 1) # burst mode is enabled
assert np.isclose(mock_ddg1.burst_delay.get(), 0)
assert np.isclose(mock_ddg1.burst_period.get(), exp_time)
# Trigger DDG2 through EXT/EN
assert np.isclose(mock_ddg1.ab.delay.get(), 2e-3)
@@ -208,13 +211,20 @@ def test_ddg1_stage(mock_ddg1):
def test_ddg1_trigger(mock_ddg1):
"""Test the on_trigger method of DDG1."""
mock_ddg1.state.event_status._read_pv.mock_data = (
5 # STATUSBITS.END_OF_DELAY.value + STATUSBITS.TRIG.value
)
status = mock_ddg1.trigger()
assert status.done is True
assert status.success is True
assert mock_ddg1.trigger_shot.get() == 1
mock_ddg1.state.event_status._read_pv.mock_data = STATUSBITS.NONE.value
with mock.patch.object(mock_ddg1, "device_manager") as mock_device_manager:
# TODO add device manager DMMock, and properly test logic for mcs triggering.
mock_get = mock_device_manager.devices.get = mock.Mock(return_value=None)
status = mock_ddg1.trigger()
assert mock_get.call_args == mock.call("mcs", None)
assert status.done is False
assert status.success is False
assert mock_ddg1.trigger_shot.get() == 1
mock_ddg1.state.event_status._read_pv.mock_data = STATUSBITS.END_OF_BURST.value
status.wait(timeout=1) # Wait for the status to be done
assert status.done is True
assert status.success is True
def test_ddg1_stop(mock_ddg1):
@@ -270,6 +280,11 @@ def test_ddg2_stage(mock_ddg2):
assert mock_ddg2.trigger_source.get() == TRIGGERSOURCE.EXT_RISING_EDGE.value
assert mock_ddg2.staged == ophyd.Staged.yes
mock_ddg2.unstage() # Reset staged state for next test
with pytest.raises(ValueError):
mock_ddg2.scan_info.msg.scan_parameters["exp_time"] = 2e-4 # too short exposure time
mock_ddg2.stage()
def test_ddg2_trigger(mock_ddg2):

View File

@@ -0,0 +1,88 @@
"""Unit tests for the IDS Camera device."""
from unittest import mock
import numpy as np
import pytest
from csaxs_bec.devices.ids_cameras.ids_camera_new import IDSCamera
@pytest.fixture(scope="function")
def ids_camera():
"""Fixture for creating an instance of the IDSCamera."""
camera = IDSCamera(
name="test_camera",
camera_id=1,
prefix="test:",
scan_info=None,
m_n_colormode=1,
bits_per_pixel=24,
live_mode=False,
)
# Mock camera connection and attributes
camera.cam = mock.Mock()
camera.cam._connected = True
camera.cam.cam = mock.Mock()
camera.cam.cam.width.value = 2
camera.cam.cam.height.value = 2
yield camera
def test_mask_setter_getter(ids_camera):
"""Test the mask setter and getter methods."""
mask = np.zeros((2, 2), dtype=np.uint8)
mask[0, 0] = 1
ids_camera.mask = mask
assert np.array_equal(ids_camera.mask, mask)
def test_mask_setter_invalid_shape(ids_camera):
"""Test the mask setter with an invalid shape."""
with pytest.raises(ValueError):
ids_camera.mask = np.zeros((3, 3), dtype=np.uint8) # Exceeds mocked camera dimensions
def test_on_connected_sets_mask_and_live_mode(ids_camera):
"""Test the on_connected method to ensure it sets the mask and live mode."""
ids_camera.cam.on_connect = mock.Mock()
ids_camera.on_connected()
ids_camera.cam.on_connect.assert_called_once()
expected_mask = np.ones((2, 2), dtype=np.uint8)
assert np.array_equal(ids_camera.mask, expected_mask)
def test_on_trigger_roi_signal(ids_camera):
"""Test the on_trigger method to ensure it processes the ROI signal correctly."""
ids_camera.live_mode = True
test_image = np.array([[2, 4], [6, 8]])
test_mask = np.array([[1, 0], [0, 1]], dtype=np.uint8)
ids_camera.mask = test_mask
mock_image = mock.Mock()
mock_image.data = test_image
ids_camera.image.get = mock.Mock(return_value=mock_image)
ids_camera.roi_signal.put = mock.Mock(side_effect=ids_camera.roi_signal.put)
ids_camera.on_trigger()
expected_value = (2 * 1 + 4 * 0 + 6 * 0 + 8 * 1) / (np.sum(test_mask) * 1)
result = ids_camera.roi_signal.get()
assert np.isclose(
result.content["signals"][ids_camera.roi_signal.name]["value"], expected_value, atol=1e-6
)
def test_get_last_image(ids_camera):
"""Test the get_last_image method to ensure it returns the last captured image."""
test_image = np.array([[1, 2], [3, 4]], dtype=np.uint8)
mock_image = mock.Mock()
mock_image.data = test_image
ids_camera.image.get = mock.Mock(return_value=mock_image)
result = ids_camera.get_last_image()
assert np.array_equal(result, test_image)
def test_on_destroy(ids_camera):
"""Test the on_destroy method to ensure it cleans up resources."""
ids_camera.cam.on_disconnect = mock.Mock()
ids_camera.on_destroy()
ids_camera.cam.on_disconnect.assert_called_once()

View File

@@ -2,311 +2,480 @@
import threading
from unittest import mock
import numpy as np
import ophyd
import pytest
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from bec_server.device_server.tests.utils import DMMock
from ophyd_devices.tests.utils import MockPV
from ophyd_devices.tests.utils import MockPV, patch_dual_pvs
from csaxs_bec.devices.epics.mcs_csaxs import (
MCScSAXS,
MCSError,
MCSTimeoutError,
ReadoutMode,
TriggerSource,
from csaxs_bec.devices.epics.mcs_card.mcs_card import (
ACQUIREMODE,
ACQUIRING,
CHANNEL1SOURCE,
CHANNELADVANCE,
INPUTMODE,
OUTPUTMODE,
POLARITY,
READMODE,
MCSCard,
)
from csaxs_bec.devices.tests_utils.utils import patch_dual_pvs
from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import READYTOREAD, MCSCardCSAXS
@pytest.fixture(scope="function")
def mock_det():
name = "mcs"
def mock_mcs_card():
"""Fixture to mock the MCSCard device."""
name = "mcs_card"
prefix = "X12SA-MCS:"
with mock.patch.object(ophyd, "cl") as mock_cl:
mock_cl.get_pv = MockPV
mock_cl.thread_class = threading
mcs_card = MCSCard(name=name, prefix=prefix)
patch_dual_pvs(mcs_card)
yield mcs_card
def test_mcs_card(mock_mcs_card):
"""Test the MCSCard initialization."""
assert mock_mcs_card.name == "mcs_card"
assert mock_mcs_card.prefix == "X12SA-MCS:"
assert len(mock_mcs_card.counters.component_names) == 32
assert mock_mcs_card.counters.mca1.name == "mcs_card_counters_mca1"
@pytest.fixture(scope="function")
def mock_mcs_csaxs():
"""Fixture to mock the MCSCardCSAXS device."""
name = "mcs_csaxs"
prefix = "X12SA-MCS-CSAXS:"
dm = DMMock()
with mock.patch.object(dm, "connector"):
with (
mock.patch(
"ophyd_devices.interfaces.base_classes.bec_device_base.FileWriter"
) as filemixin,
mock.patch(
"ophyd_devices.interfaces.base_classes.psi_detector_base.PSIDetectorBase._update_service_config"
) as mock_service_config,
):
with mock.patch.object(ophyd, "cl") as mock_cl:
mock_cl.get_pv = MockPV
mock_cl.thread_class = threading.Thread
with mock.patch.object(MCScSAXS, "_init"):
det = MCScSAXS(name=name, prefix=prefix, device_manager=dm)
patch_dual_pvs(det)
det.TIMEOUT_FOR_SIGNALS = 0.1
yield det
with mock.patch.object(ophyd, "cl") as mock_cl:
mock_cl.get_pv = MockPV
mock_cl.thread_class = threading.Thread
mcs_card_csaxs = MCSCardCSAXS(name=name, prefix=prefix, device_manager=dm)
patch_dual_pvs(mcs_card_csaxs)
yield mcs_card_csaxs
def test_init():
"""Test the _init function:"""
name = "eiger"
prefix = "X12SA-ES-EIGER9M:"
dm = DMMock()
with mock.patch.object(dm, "connector"):
with (
mock.patch("ophyd_devices.interfaces.base_classes.bec_device_base.FileWriter"),
mock.patch(
"ophyd_devices.interfaces.base_classes.psi_detector_base.PSIDetectorBase._update_service_config"
),
):
with mock.patch.object(ophyd, "cl") as mock_cl:
mock_cl.get_pv = MockPV
with (
mock.patch(
"csaxs_bec.devices.epics.mcs_csaxs.MCSSetup.initialize_detector"
) as mock_init_det,
mock.patch(
"csaxs_bec.devices.epics.mcs_csaxs.MCSSetup.initialize_detector_backend"
) as mock_init_backend,
):
MCScSAXS(name=name, prefix=prefix, device_manager=dm)
mock_init_det.assert_called_once()
mock_init_backend.assert_called_once()
def test_mcs_card_csaxs(mock_mcs_csaxs):
"""Test the MCSCardCSAXS initialization."""
assert mock_mcs_csaxs.name == "mcs_csaxs"
assert mock_mcs_csaxs.prefix == "X12SA-MCS-CSAXS:"
assert mock_mcs_csaxs.counter_mapping == {
"mcs_csaxs_counters_mca1": "current1",
"mcs_csaxs_counters_mca2": "current2",
"mcs_csaxs_counters_mca3": "current3",
"mcs_csaxs_counters_mca4": "current4",
"mcs_csaxs_counters_mca5": "count_time",
}
assert mock_mcs_csaxs._mcs_clock == 1e7 # 10 MHz
@pytest.mark.parametrize(
"trigger_source, channel_advance, channel_source1, pv_channels",
[
(
3,
1,
0,
{
"user_led": 0,
"mux_output": 5,
"input_pol": 0,
"output_pol": 1,
"count_on_start": 0,
"stop_all": 1,
},
)
],
)
def test_initialize_detector(
mock_det, trigger_source, channel_advance, channel_source1, pv_channels
):
"""Test the _init function:
def test_mcs_card_csaxs_on_connected(mock_mcs_csaxs):
"""Test the on_connected method of MCSCardCSAXS."""
mcs = mock_mcs_csaxs
mcs.on_connected()
# Stop called
assert mcs.stop_all.get() == 1
# Channel advance settings
assert mcs.channel_advance.get() == CHANNELADVANCE.EXTERNAL
assert mcs.channel1_source.get() == CHANNEL1SOURCE.EXTERNAL
assert mcs.prescale.get() == 1
#
assert mcs.user_led.get() == 0
# Only 5 channels are connected
assert mcs.mux_output.get() == 5
# input output settings
assert mcs.input_mode.get() == INPUTMODE.MODE_3
assert mcs.input_polarity.get() == POLARITY.NORMAL
assert mcs.output_mode.get() == OUTPUTMODE.MODE_2
assert mcs.output_polarity.get() == POLARITY.NORMAL
assert mcs.count_on_start.get() == 0
assert mcs.read_mode.get() == READMODE.PASSIVE
assert mcs.acquire_mode.get() == ACQUIREMODE.MCS
This includes testing the functions:
- initialize_detector
- stop_det
- parent.set_trigger
--> Testing the filewriter is done in test_init_filewriter
Validation upon setting the correct PVs
"""
mock_det.custom_prepare.initialize_detector() # call the method you want to test
assert mock_det.channel_advance.get() == channel_advance
assert mock_det.channel1_source.get() == channel_source1
assert mock_det.user_led.get() == pv_channels["user_led"]
assert mock_det.mux_output.get() == pv_channels["mux_output"]
assert mock_det.input_polarity.get() == pv_channels["input_pol"]
assert mock_det.output_polarity.get() == pv_channels["output_pol"]
assert mock_det.count_on_start.get() == pv_channels["count_on_start"]
assert mock_det.input_mode.get() == trigger_source
with mock.patch.object(mcs.current_channel, "subscribe") as mock_cur_ch_subscribe:
with mock.patch.object(mcs.counters.mca1, "subscribe") as mock_mca_subscribe:
mcs.on_connected()
assert mock_cur_ch_subscribe.call_args == mock.call(mcs._progress_update, run=False)
assert mock_mca_subscribe.call_args == mock.call(mcs._on_counter_update, run=False)
def test_trigger(mock_det):
"""Test the trigger function:
Validate that trigger calls the custom_prepare.on_trigger() function
"""
with mock.patch.object(mock_det.custom_prepare, "on_trigger") as mock_on_trigger:
mock_det.trigger()
mock_on_trigger.assert_called_once()
def test_mcs_card_csaxs_stage(mock_mcs_csaxs):
"""Test on stage method of MCSCardCSAXS"""
mcs = mock_mcs_csaxs
triggers = 5
mcs.scan_info.msg.scan_parameters["frames_per_trigger"] = triggers
mcs.erase_all.put(0)
mcs.stage()
assert mcs._staged == ophyd.Staged.yes
assert mcs.erase_all.get() == 1
assert mcs.preset_real.get() == 0
assert mcs.num_use_all.get() == triggers
@pytest.mark.parametrize(
"value, num_lines, num_points, done", [(100, 5, 500, False), (500, 5, 500, True)]
)
def test_progress_update(mock_det, value, num_lines, num_points, done):
mock_det.num_lines.set(num_lines)
mock_det.scaninfo.num_points = num_points
calls = mock.call(sub_type="progress", value=value, max_value=num_points, done=done)
with mock.patch.object(mock_det, "_run_subs") as mock_run_subs:
mock_det.custom_prepare._progress_update(value=value)
mock_run_subs.assert_called_once()
assert mock_run_subs.call_args == calls
def test_mcs_card_csaxs_unstage(mock_mcs_csaxs):
"""Test unstage method of MCSCardCSAXS"""
mcs = mock_mcs_csaxs
mcs.stop_all.put(0)
mcs.ready_to_read.put(0)
mcs.erase_all.put(1)
mcs.unstage()
assert mcs.stop_all.get() == 1
assert mcs.ready_to_read.get() == READYTOREAD.DONE
assert mcs.erase_all.get() == 0
@pytest.mark.parametrize(
"values, expected_nothing",
[([[100, 120, 140], [200, 220, 240], [300, 320, 340]], False), ([100, 200, 300], True)],
)
def test_on_mca_data(mock_det, values, expected_nothing):
"""Test the on_mca_data function:
Validate that on_mca_data calls the custom_prepare.on_mca_data() function
"""
with mock.patch.object(mock_det.custom_prepare, "_send_data_to_bec") as mock_send_data:
mock_object = mock.MagicMock()
for ii, name in enumerate(mock_det.custom_prepare.mca_names):
mock_object.attr_name = name
mock_det.custom_prepare._on_mca_data(obj=mock_object, value=values[ii])
if not expected_nothing and ii < (len(values) - 1):
assert mock_det.custom_prepare.mca_data[name] == values[ii]
if not expected_nothing:
mock_send_data.assert_called_once()
assert mock_det.custom_prepare.acquisition_done is True
def test_mcs_card_csaxs_complete_and_stop(mock_mcs_csaxs):
"""Test complete method of MCSCarcCSAXS"""
mcs = mock_mcs_csaxs
mcs.acquiring._read_pv.mock_data = ACQUIRING.ACQUIRING
st = mcs.complete()
assert st.done is False
mcs.stop_all.put(0)
mcs.ready_to_read.put(READYTOREAD.PROCESSING)
mcs.stop()
with pytest.raises(Exception):
st.wait(timeout=3)
assert st.done is True
assert st.success is False
assert mcs.stop_all.get() == 1
assert mcs.ready_to_read.get() == READYTOREAD.DONE
@pytest.mark.parametrize(
"metadata, mca_data",
[
(
{"scan_id": 123},
{
"mca1": {"value": [100, 120, 140]},
"mca3": {"value": [200, 220, 240]},
"mca4": {"value": [300, 320, 340]},
},
)
],
)
def test_send_data_to_bec(mock_det, metadata, mca_data):
mock_det.scaninfo.scan_msg = mock.MagicMock()
mock_det.scaninfo.scan_msg.metadata = metadata
mock_det.scaninfo.scan_id = metadata["scan_id"]
mock_det.custom_prepare.mca_data = mca_data
mock_det.custom_prepare._send_data_to_bec()
device_metadata = mock_det.scaninfo.scan_msg.metadata
metadata.update({"async_update": "append", "num_lines": mock_det.num_lines.get()})
data = messages.DeviceMessage(signals=dict(mca_data), metadata=device_metadata)
calls = mock.call(
topic=MessageEndpoints.device_async_readback(
scan_id=metadata["scan_id"], device=mock_det.name
),
msg={"data": data},
expire=1800,
)
assert mock_det.connector.xadd.call_args == calls
def test_mcs_card_csaxs_on_counter_updated(mock_mcs_csaxs):
mcs = mock_mcs_csaxs
# Called for mca1
kwargs = {"obj": mcs.counters.mca1}
mcs._on_counter_update(1, **kwargs)
assert mcs.mcs.mca1.get() == 1
assert mcs.bpm.current1.get() == 1
assert mcs.counter_updated == [mcs.counters.mca1.name]
# Called for mca2
kwargs = {"obj": mcs.counters.mca2}
mcs._on_counter_update(np.array([2, 4]), **kwargs)
assert mcs.mcs.mca2.get() == [2, 4]
assert np.isclose(mcs.bpm.current2.get(), 3)
assert mcs.counter_updated == [mcs.counters.mca1.name, mcs.counters.mca2.name]
# Called for mca3
kwargs = {"obj": mcs.counters.mca3}
mcs._on_counter_update(1000, **kwargs)
assert mcs.mcs.mca3.get() == 1000
assert mcs.bpm.current3.get() == 1000
assert mcs.counter_updated == [
mcs.counters.mca1.name,
mcs.counters.mca2.name,
mcs.counters.mca3.name,
]
# Called for mca4
kwargs = {"obj": mcs.counters.mca4}
mcs._on_counter_update(np.array([20, 40]), **kwargs)
assert mcs.mcs.mca4.get() == [20, 40]
assert np.isclose(mcs.bpm.current4.get(), 30)
assert mcs.counter_updated == [
mcs.counters.mca1.name,
mcs.counters.mca2.name,
mcs.counters.mca3.name,
mcs.counters.mca4.name,
]
# Called for mca5
assert mcs.ready_to_read.get() == 0
kwargs = {"obj": mcs.counters.mca5}
mcs._on_counter_update(np.array([10000, 10000]), **kwargs)
assert np.isclose(mcs.bpm.count_time.get(), 10000 / 1e7)
assert mcs.mcs.mca5.get() == [10000, 10000]
@pytest.mark.parametrize(
"scaninfo, triggersource, stopped, expected_exception",
[
(
{"num_points": 500, "frames_per_trigger": 1, "scan_type": "step"},
TriggerSource.MODE3,
False,
False,
),
(
{"num_points": 500, "frames_per_trigger": 1, "scan_type": "fly"},
TriggerSource.MODE3,
False,
False,
),
(
{"num_points": 5001, "frames_per_trigger": 2, "scan_type": "step"},
TriggerSource.MODE3,
False,
True,
),
(
{"num_points": 500, "frames_per_trigger": 2, "scan_type": "random"},
TriggerSource.MODE3,
False,
True,
),
],
)
def test_stage(mock_det, scaninfo, triggersource, stopped, expected_exception):
mock_det.scaninfo.num_points = scaninfo["num_points"]
mock_det.scaninfo.frames_per_trigger = scaninfo["frames_per_trigger"]
mock_det.scaninfo.scan_type = scaninfo["scan_type"]
mock_det.stopped = stopped
with mock.patch.object(mock_det.custom_prepare, "prepare_detector_backend") as mock_prep_fw:
if expected_exception:
with pytest.raises(MCSError):
mock_det.stage()
mock_prep_fw.assert_called_once()
else:
mock_det.stage()
mock_prep_fw.assert_called_once()
# Check set_trigger
mock_det.input_mode.get() == triggersource
if scaninfo["scan_type"] == "step":
assert mock_det.num_use_all.get() == int(scaninfo["frames_per_trigger"]) * int(
scaninfo["num_points"]
)
elif scaninfo["scan_type"] == "fly":
assert mock_det.num_use_all.get() == int(scaninfo["num_points"])
mock_det.preset_real.get() == 0
# # CHeck custom_prepare.arm_acquisition
# assert mock_det.custom_prepare.counter == 0
# assert mock_det.erase_start.get() == 1
# mock_prep_fw.assert_called_once()
# # Check _prep_det
# assert mock_det.cam.num_images.get() == int(
# scaninfo["num_points"] * scaninfo["frames_per_trigger"]
# )
# assert mock_det.cam.num_frames.get() == 1
# mock_publish_file_location.assert_called_with(done=False)
# assert mock_det.cam.acquire.get() == 1
# @pytest.fixture(scope="function")
# def mock_det():
# name = "mcs"
# prefix = "X12SA-MCS:"
# dm = DMMock()
# with mock.patch.object(dm, "connector"):
# with (
# mock.patch(
# "ophyd_devices.interfaces.base_classes.bec_device_base.FileWriter"
# ) as filemixin,
# mock.patch(
# "ophyd_devices.interfaces.base_classes.psi_detector_base.PSIDetectorBase._update_service_config"
# ) as mock_service_config,
# ):
# with mock.patch.object(ophyd, "cl") as mock_cl:
# mock_cl.get_pv = MockPV
# mock_cl.thread_class = threading.Thread
# with mock.patch.object(MCScSAXS, "_init"):
# det = MCScSAXS(name=name, prefix=prefix, device_manager=dm)
# patch_dual_pvs(det)
# det.TIMEOUT_FOR_SIGNALS = 0.1
# yield det
def test_prepare_detector_backend(mock_det):
mock_det.custom_prepare.prepare_detector_backend()
assert mock_det.erase_all.get() == 1
assert mock_det.read_mode.get() == ReadoutMode.EVENT
# def test_init():
# """Test the _init function:"""
# name = "eiger"
# prefix = "X12SA-ES-EIGER9M:"
# dm = DMMock()
# with mock.patch.object(dm, "connector"):
# with (
# mock.patch("ophyd_devices.interfaces.base_classes.bec_device_base.FileWriter"),
# mock.patch(
# "ophyd_devices.interfaces.base_classes.psi_detector_base.PSIDetectorBase._update_service_config"
# ),
# ):
# with mock.patch.object(ophyd, "cl") as mock_cl:
# mock_cl.get_pv = MockPV
# with (
# mock.patch(
# "csaxs_bec.devices.epics.mcs_csaxs.MCSSetup.initialize_detector"
# ) as mock_init_det,
# mock.patch(
# "csaxs_bec.devices.epics.mcs_csaxs.MCSSetup.initialize_detector_backend"
# ) as mock_init_backend,
# ):
# MCScSAXS(name=name, prefix=prefix, device_manager=dm)
# mock_init_det.assert_called_once()
# mock_init_backend.assert_called_once()
def test_complete(mock_det):
with (mock.patch.object(mock_det.custom_prepare, "finished") as mock_finished,):
mock_det.complete()
assert mock_finished.call_count == 1
# @pytest.mark.parametrize(
# "trigger_source, channel_advance, channel_source1, pv_channels",
# [
# (
# 3,
# 1,
# 0,
# {
# "user_led": 0,
# "mux_output": 5,
# "input_pol": 0,
# "output_pol": 1,
# "count_on_start": 0,
# "stop_all": 1,
# },
# )
# ],
# )
# def test_initialize_detector(
# mock_det, trigger_source, channel_advance, channel_source1, pv_channels
# ):
# """Test the _init function:
# This includes testing the functions:
# - initialize_detector
# - stop_det
# - parent.set_trigger
# --> Testing the filewriter is done in test_init_filewriter
# Validation upon setting the correct PVs
# """
# mock_det.custom_prepare.initialize_detector() # call the method you want to test
# assert mock_det.channel_advance.get() == channel_advance
# assert mock_det.channel1_source.get() == channel_source1
# assert mock_det.user_led.get() == pv_channels["user_led"]
# assert mock_det.mux_output.get() == pv_channels["mux_output"]
# assert mock_det.input_polarity.get() == pv_channels["input_pol"]
# assert mock_det.output_polarity.get() == pv_channels["output_pol"]
# assert mock_det.count_on_start.get() == pv_channels["count_on_start"]
# assert mock_det.input_mode.get() == trigger_source
def test_stop_detector_backend(mock_det):
mock_det.custom_prepare.stop_detector_backend()
assert mock_det.custom_prepare.acquisition_done is True
# def test_trigger(mock_det):
# """Test the trigger function:
# Validate that trigger calls the custom_prepare.on_trigger() function
# """
# with mock.patch.object(mock_det.custom_prepare, "on_trigger") as mock_on_trigger:
# mock_det.trigger()
# mock_on_trigger.assert_called_once()
def test_stop(mock_det):
with (
mock.patch.object(mock_det.custom_prepare, "stop_detector") as mock_stop_det,
mock.patch.object(
mock_det.custom_prepare, "stop_detector_backend"
) as mock_stop_detector_backend,
):
mock_det.stop()
mock_stop_det.assert_called_once()
mock_stop_detector_backend.assert_called_once()
assert mock_det.stopped is True
# @pytest.mark.parametrize(
# "value, num_lines, num_points, done", [(100, 5, 500, False), (500, 5, 500, True)]
# )
# def test_progress_update(mock_det, value, num_lines, num_points, done):
# mock_det.num_lines.set(num_lines)
# mock_det.scaninfo.num_points = num_points
# calls = mock.call(sub_type="progress", value=value, max_value=num_points, done=done)
# with mock.patch.object(mock_det, "_run_subs") as mock_run_subs:
# mock_det.custom_prepare._progress_update(value=value)
# mock_run_subs.assert_called_once()
# assert mock_run_subs.call_args == calls
@pytest.mark.parametrize(
"stopped, acquisition_done, acquiring_state, expected_exception",
[
(False, True, 0, False),
(False, False, 0, True),
(False, True, 1, True),
(True, True, 0, True),
],
)
def test_finished(mock_det, stopped, acquisition_done, acquiring_state, expected_exception):
mock_det.custom_prepare.acquisition_done = acquisition_done
mock_det.acquiring._read_pv.mock_data = acquiring_state
mock_det.scaninfo.num_points = 500
mock_det.num_lines.put(500)
mock_det.current_channel._read_pv.mock_data = 1
mock_det.stopped = stopped
# @pytest.mark.parametrize(
# "values, expected_nothing",
# [([[100, 120, 140], [200, 220, 240], [300, 320, 340]], False), ([100, 200, 300], True)],
# )
# def test_on_mca_data(mock_det, values, expected_nothing):
# """Test the on_mca_data function:
# Validate that on_mca_data calls the custom_prepare.on_mca_data() function
# """
# with mock.patch.object(mock_det.custom_prepare, "_send_data_to_bec") as mock_send_data:
# mock_object = mock.MagicMock()
# for ii, name in enumerate(mock_det.custom_prepare.mca_names):
# mock_object.attr_name = name
# mock_det.custom_prepare._on_mca_data(obj=mock_object, value=values[ii])
# if not expected_nothing and ii < (len(values) - 1):
# assert mock_det.custom_prepare.mca_data[name] == values[ii]
if expected_exception:
with pytest.raises(MCSTimeoutError):
mock_det.timeout = 0.1
mock_det.custom_prepare.finished()
else:
mock_det.custom_prepare.finished()
if stopped:
assert mock_det.stopped is stopped
# if not expected_nothing:
# mock_send_data.assert_called_once()
# assert mock_det.custom_prepare.acquisition_done is True
# @pytest.mark.parametrize(
# "metadata, mca_data",
# [
# (
# {"scan_id": 123},
# {
# "mca1": {"value": [100, 120, 140]},
# "mca3": {"value": [200, 220, 240]},
# "mca4": {"value": [300, 320, 340]},
# },
# )
# ],
# )
# def test_send_data_to_bec(mock_det, metadata, mca_data):
# mock_det.scaninfo.scan_msg = mock.MagicMock()
# mock_det.scaninfo.scan_msg.metadata = metadata
# mock_det.scaninfo.scan_id = metadata["scan_id"]
# mock_det.custom_prepare.mca_data = mca_data
# mock_det.custom_prepare._send_data_to_bec()
# device_metadata = mock_det.scaninfo.scan_msg.metadata
# metadata.update({"async_update": "append", "num_lines": mock_det.num_lines.get()})
# data = messages.DeviceMessage(signals=dict(mca_data), metadata=device_metadata)
# calls = mock.call(
# topic=MessageEndpoints.device_async_readback(
# scan_id=metadata["scan_id"], device=mock_det.name
# ),
# msg={"data": data},
# expire=1800,
# )
# assert mock_det.connector.xadd.call_args == calls
# @pytest.mark.parametrize(
# "scaninfo, triggersource, stopped, expected_exception",
# [
# (
# {"num_points": 500, "frames_per_trigger": 1, "scan_type": "step"},
# TriggerSource.MODE3,
# False,
# False,
# ),
# (
# {"num_points": 500, "frames_per_trigger": 1, "scan_type": "fly"},
# TriggerSource.MODE3,
# False,
# False,
# ),
# (
# {"num_points": 5001, "frames_per_trigger": 2, "scan_type": "step"},
# TriggerSource.MODE3,
# False,
# True,
# ),
# (
# {"num_points": 500, "frames_per_trigger": 2, "scan_type": "random"},
# TriggerSource.MODE3,
# False,
# True,
# ),
# ],
# )
# def test_stage(mock_det, scaninfo, triggersource, stopped, expected_exception):
# mock_det.scaninfo.num_points = scaninfo["num_points"]
# mock_det.scaninfo.frames_per_trigger = scaninfo["frames_per_trigger"]
# mock_det.scaninfo.scan_type = scaninfo["scan_type"]
# mock_det.stopped = stopped
# with mock.patch.object(mock_det.custom_prepare, "prepare_detector_backend") as mock_prep_fw:
# if expected_exception:
# with pytest.raises(MCSError):
# mock_det.stage()
# mock_prep_fw.assert_called_once()
# else:
# mock_det.stage()
# mock_prep_fw.assert_called_once()
# # Check set_trigger
# mock_det.input_mode.get() == triggersource
# if scaninfo["scan_type"] == "step":
# assert mock_det.num_use_all.get() == int(scaninfo["frames_per_trigger"]) * int(
# scaninfo["num_points"]
# )
# elif scaninfo["scan_type"] == "fly":
# assert mock_det.num_use_all.get() == int(scaninfo["num_points"])
# mock_det.preset_real.get() == 0
# # # CHeck custom_prepare.arm_acquisition
# # assert mock_det.custom_prepare.counter == 0
# # assert mock_det.erase_start.get() == 1
# # mock_prep_fw.assert_called_once()
# # # Check _prep_det
# # assert mock_det.cam.num_images.get() == int(
# # scaninfo["num_points"] * scaninfo["frames_per_trigger"]
# # )
# # assert mock_det.cam.num_frames.get() == 1
# # mock_publish_file_location.assert_called_with(done=False)
# # assert mock_det.cam.acquire.get() == 1
# def test_prepare_detector_backend(mock_det):
# mock_det.custom_prepare.prepare_detector_backend()
# assert mock_det.erase_all.get() == 1
# assert mock_det.read_mode.get() == ReadoutMode.EVENT
# def test_complete(mock_det):
# with (mock.patch.object(mock_det.custom_prepare, "finished") as mock_finished,):
# mock_det.complete()
# assert mock_finished.call_count == 1
# def test_stop_detector_backend(mock_det):
# mock_det.custom_prepare.stop_detector_backend()
# assert mock_det.custom_prepare.acquisition_done is True
# def test_stop(mock_det):
# with (
# mock.patch.object(mock_det.custom_prepare, "stop_detector") as mock_stop_det,
# mock.patch.object(
# mock_det.custom_prepare, "stop_detector_backend"
# ) as mock_stop_detector_backend,
# ):
# mock_det.stop()
# mock_stop_det.assert_called_once()
# mock_stop_detector_backend.assert_called_once()
# assert mock_det.stopped is True
# @pytest.mark.parametrize(
# "stopped, acquisition_done, acquiring_state, expected_exception",
# [
# (False, True, 0, False),
# (False, False, 0, True),
# (False, True, 1, True),
# (True, True, 0, True),
# ],
# )
# def test_finished(mock_det, stopped, acquisition_done, acquiring_state, expected_exception):
# mock_det.custom_prepare.acquisition_done = acquisition_done
# mock_det.acquiring._read_pv.mock_data = acquiring_state
# mock_det.scaninfo.num_points = 500
# mock_det.num_lines.put(500)
# mock_det.current_channel._read_pv.mock_data = 1
# mock_det.stopped = stopped
# if expected_exception:
# with pytest.raises(MCSTimeoutError):
# mock_det.timeout = 0.1
# mock_det.custom_prepare.finished()
# else:
# mock_det.custom_prepare.finished()
# if stopped:
# assert mock_det.stopped is stopped