From e3323e521e6a001d3201165262248d5be2876971 Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 12:55:24 +0200 Subject: [PATCH 01/15] feat(ids-cameras): add manual exposure / auto-gain control signals Adds exposure_time/auto_exposure_enabled/auto_gain_enabled as Kind.config Signals on IDSCamera (mirroring live_mode_enabled), with USER_ACCESS wrappers and hardware seeding of exposure_time on connect. Extends SimIDSCamera's backend to match, and adds unit test coverage. Per docs/plans/ids-camera-manual-exposure.md. --- csaxs_bec/devices/ids_cameras/ids_camera.py | 64 +++++++++++++++++++++ csaxs_bec/devices/sim/sim_cameras.py | 17 ++++++ tests/tests_devices/test_ids_camera.py | 23 ++++++++ 3 files changed, 104 insertions(+) diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py index 7f63241a..8c692318 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera.py @@ -63,6 +63,27 @@ class IDSCamera(PSIDeviceBase): doc="Enable or disable live mode.", kind=Kind.config, ) + exposure_time = Cpt( + Signal, + name="exposure_time", + value=0.0, + doc="Camera exposure time (ms).", + kind=Kind.config, + ) + auto_exposure_enabled = Cpt( + Signal, + name="auto_exposure_enabled", + value=True, + doc="Enable/disable auto-exposure (auto-shutter).", + kind=Kind.config, + ) + auto_gain_enabled = Cpt( + Signal, + name="auto_gain_enabled", + value=True, + doc="Enable/disable auto-gain.", + kind=Kind.config, + ) USER_ACCESS = [ "start_live_mode", @@ -73,6 +94,10 @@ class IDSCamera(PSIDeviceBase): "push_preview_image", "push_smear_preview", "get_live_fps", + "get_exposure_time", + "set_exposure_time", + "set_auto_exposure_enabled", + "set_auto_gain_enabled", ] def __init__( @@ -125,6 +150,9 @@ class IDSCamera(PSIDeviceBase): self.image.transpose = transpose self._force_monochrome = force_monochrome self.live_mode_enabled.subscribe(self._on_live_mode_enabled_changed, run=False) + self.exposure_time.subscribe(self._on_exposure_time_changed, run=False) + self.auto_exposure_enabled.subscribe(self._on_auto_exposure_enabled_changed, run=False) + self.auto_gain_enabled.subscribe(self._on_auto_gain_enabled_changed, run=False) self.live_mode_enabled.put(bool(live_mode)) ############## Live Mode Methods ############## @@ -278,6 +306,33 @@ class IDSCamera(PSIDeviceBase): """ self.smear_preview.put(data) + ############## Exposure / Gain ############## + + def _on_exposure_time_changed(self, *args, value, **kwargs): + self.cam.exposure_time = value + + def _on_auto_exposure_enabled_changed(self, *args, value, **kwargs): + self.cam.set_auto_shutter(bool(value)) + + def _on_auto_gain_enabled_changed(self, *args, value, **kwargs): + self.cam.set_auto_gain(bool(value)) + + def get_exposure_time(self) -> float: + """Get the current exposure time (ms), from the cached Signal value.""" + return float(self.exposure_time.get()) + + def set_exposure_time(self, value: float) -> None: + """Set the exposure time (ms). Does not itself disable auto-exposure -- + call set_auto_exposure_enabled(False) first, or the driver will keep + overriding it.""" + self.exposure_time.put(value) + + def set_auto_exposure_enabled(self, enable: bool) -> None: + self.auto_exposure_enabled.put(bool(enable)) + + def set_auto_gain_enabled(self, enable: bool) -> None: + self.auto_gain_enabled.put(bool(enable)) + ############## User Interface Methods ############## def on_connected(self): @@ -285,6 +340,15 @@ class IDSCamera(PSIDeviceBase): self.cam.force_monochrome = self._force_monochrome self.cam.on_connect() self.live_mode_enabled.put(bool(self._inputs.get("live_mode", False))) + # Seed exposure_time from the real hardware value once, so the GUI shows + # a real number immediately on connect instead of the 0.0 placeholder. + # This round-trips through _on_exposure_time_changed, which writes the + # same value back to the driver -- a harmless one-time no-op write. + # auto_exposure_enabled/auto_gain_enabled are not seeded from hardware -- + # there is no SDK query for current auto-shutter/auto-gain state in this + # driver (only enable-setters), so they start at their declared default + # (True) and only reflect reality once explicitly set through this API. + self.exposure_time.put(self.cam.exposure_time) self.set_rect_roi(0, 0, self.cam.cam.width.value, self.cam.cam.height.value) def on_destroy(self): diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index 98c40e18..c5cd44f4 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -238,6 +238,9 @@ class _SimIDSBackend: self._connected = False self._rgb = rgb self._noise_std = float(noise_std) + self._exposure_time = 10000.0 # ms + self._auto_exposure = True + self._auto_gain = True self._width = width self._height = height self._rotation_coupling = rotation_coupling @@ -282,6 +285,20 @@ class _SimIDSBackend: def on_disconnect(self): self._connected = False + @property + def exposure_time(self) -> float: + return self._exposure_time + + @exposure_time.setter + def exposure_time(self, value: float): + self._exposure_time = value + + def set_auto_shutter(self, enable: bool): + self._auto_exposure = bool(enable) + + def set_auto_gain(self, enable: bool): + self._auto_gain = bool(enable) + def _current_angle_deg(self) -> float: coupling = self._rotation_coupling galil = SimStateRegistry.get( diff --git a/tests/tests_devices/test_ids_camera.py b/tests/tests_devices/test_ids_camera.py index 2279e10e..1587f0a8 100644 --- a/tests/tests_devices/test_ids_camera.py +++ b/tests/tests_devices/test_ids_camera.py @@ -148,6 +148,29 @@ def test_push_preview_image_compensates_rotation_and_transpose(): assert np.array_equal(result, display_oriented) +def test_get_set_exposure_time(ids_camera): + ids_camera.set_exposure_time(1234.5) + assert ids_camera.cam.exposure_time == 1234.5 + assert ids_camera.get_exposure_time() == 1234.5 + + +def test_set_auto_exposure_enabled(ids_camera): + ids_camera.set_auto_exposure_enabled(False) + ids_camera.cam.set_auto_shutter.assert_called_once_with(False) + + +def test_set_auto_gain_enabled(ids_camera): + ids_camera.set_auto_gain_enabled(False) + ids_camera.cam.set_auto_gain.assert_called_once_with(False) + + +def test_on_connected_seeds_exposure_time(ids_camera): + ids_camera.cam.on_connect = mock.Mock() + ids_camera.cam.exposure_time = 4200.0 + ids_camera.on_connected() + assert ids_camera.get_exposure_time() == 4200.0 + + def test_push_smear_preview_no_rotation_compensation(ids_camera): """smear_preview has no rotation_90/transpose configured, so pushed data passes straight through unmodified -- unlike push_preview_image, no -- 2.54.0 From 713eaed245a15770793857e3a20173750f605d51 Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 12:55:32 +0200 Subject: [PATCH 02/15] feat(xrayeye): add exposure/auto-gain control knobs to OMNY_XRayEye Adds "Auto exposure"/"Auto gain" toggles and an exposure-time spinbox to the xrayeye widget's control panel, following the existing cached/event-driven pattern (no polling): writes go through RPC .put() on IDSCamera's new config signals, reads come from the same device_read_configuration message already used for live_mode_enabled. Phase 2 of docs/plans/ids-camera-manual-exposure.md. --- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 88 ++++++++++++++++++- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 6431f5ed..a9c57ed7 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -13,6 +13,7 @@ from bec_widgets.widgets.plots.roi.image_roi import BaseROI, CircularROI, Rectan from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( + QDoubleSpinBox, QFrame, QGridLayout, QHBoxLayout, @@ -401,6 +402,24 @@ class OMNY_XRayEye(BECWidget, QWidget): switch_grid.addWidget(self.smear_active_toggle, 1, 2, Qt.AlignmentFlag.AlignVCenter) switch_grid.addWidget(self.smear_preview_label, 1, 3, _right_vcenter) switch_grid.addWidget(self.smear_preview_toggle, 1, 4, Qt.AlignmentFlag.AlignVCenter) + + # Exposure/gain control knobs (row 2): auto-exposure and auto-gain + # enable toggles, mirroring the shutter/camera-running row above. + self.auto_exposure_label = QLabel("Auto exposure", parent=self) + self.auto_exposure_toggle = ToggleSwitch(parent=self) + self.auto_exposure_toggle.checked = True + self.auto_exposure_toggle.enabled.connect(self.auto_exposure_enabled_changed) + + self.auto_gain_label = QLabel("Auto gain", parent=self) + self.auto_gain_toggle = ToggleSwitch(parent=self) + self.auto_gain_toggle.checked = True + self.auto_gain_toggle.enabled.connect(self.auto_gain_enabled_changed) + + switch_grid.addWidget(self.auto_exposure_label, 2, 1, _right_vcenter) + switch_grid.addWidget(self.auto_exposure_toggle, 2, 2, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.auto_gain_label, 2, 3, _right_vcenter) + switch_grid.addWidget(self.auto_gain_toggle, 2, 4, Qt.AlignmentFlag.AlignVCenter) + self.control_panel_layout.addWidget(self.switch_grid_widget) # separator @@ -438,6 +457,18 @@ class OMNY_XRayEye(BECWidget, QWidget): # Add form to control panel self.control_panel_layout.addLayout(step_size_form) + # Exposure time (manual entry, only usable while auto-exposure is off) + exposure_form = QGridLayout() + self.exposure_time_spin = QDoubleSpinBox(parent=self) + self.exposure_time_spin.setRange(0.01, 1000.0) # ms; confirm real driver range + self.exposure_time_spin.setDecimals(2) + self.exposure_time_spin.setSuffix(" ms") + self.exposure_time_spin.setEnabled(False) # auto-exposure starts enabled + self.exposure_time_spin.editingFinished.connect(self.exposure_time_submitted) + exposure_form.addWidget(QLabel("Exposure time", parent=self), 0, 0) + exposure_form.addWidget(self.exposure_time_spin, 0, 1) + self.control_panel_layout.addLayout(exposure_form) + # Push form to bottom self.control_panel_layout.addStretch() @@ -648,8 +679,14 @@ class OMNY_XRayEye(BECWidget, QWidget): def enable_move_buttons(self, enabled: bool): self.motor_control_2d.setEnabled(enabled) - def _queue_guarded_toggles(self) -> tuple[ToggleSwitch, ToggleSwitch, ToggleSwitch]: - return (self.live_preview_toggle, self.shutter_toggle, self.camera_running_toggle) + def _queue_guarded_toggles(self) -> tuple[ToggleSwitch, ...]: + return ( + self.live_preview_toggle, + self.shutter_toggle, + self.camera_running_toggle, + self.auto_exposure_toggle, + self.auto_gain_toggle, + ) def _set_queue_toggles_blocked(self, blocked: bool): if blocked == self._queue_busy: @@ -886,11 +923,56 @@ class OMNY_XRayEye(BECWidget, QWidget): @SafeSlot(dict, dict) def getting_camera_status(self, data, meta): print(f"msg:{data}") - live_mode_enabled = data.get("signals").get(f"{CAMERA[0]}_live_mode_enabled").get("value") + signals = data.get("signals") + live_mode_enabled = signals.get(f"{CAMERA[0]}_live_mode_enabled").get("value") self.camera_running_toggle.blockSignals(True) self.camera_running_toggle.checked = live_mode_enabled self.camera_running_toggle.blockSignals(False) + auto_exp = signals.get(f"{CAMERA[0]}_auto_exposure_enabled") + if auto_exp is not None: + enabled = bool(auto_exp.get("value")) + self.auto_exposure_toggle.blockSignals(True) + self.auto_exposure_toggle.checked = enabled + self.exposure_time_spin.setEnabled(not enabled) + self.auto_exposure_toggle.blockSignals(False) + + auto_gain = signals.get(f"{CAMERA[0]}_auto_gain_enabled") + if auto_gain is not None: + self.auto_gain_toggle.blockSignals(True) + self.auto_gain_toggle.checked = bool(auto_gain.get("value")) + self.auto_gain_toggle.blockSignals(False) + + exposure_time = signals.get(f"{CAMERA[0]}_exposure_time") + if exposure_time is not None: + self.exposure_time_spin.blockSignals(True) + self.exposure_time_spin.setValue(float(exposure_time.get("value"))) + self.exposure_time_spin.blockSignals(False) + + @SafeSlot(bool) + def auto_exposure_enabled_changed(self, enabled: bool): + if self._manual_toggle_blocked_by_queue(): + logger.warning("Ignoring auto-exposure toggle while scan queue is busy.") + return + self.auto_exposure_toggle.blockSignals(True) + self.dev.get(CAMERA[0]).auto_exposure_enabled.put(enabled) + self.auto_exposure_toggle.checked = enabled + self.exposure_time_spin.setEnabled(not enabled) + self.auto_exposure_toggle.blockSignals(False) + + @SafeSlot(bool) + def auto_gain_enabled_changed(self, enabled: bool): + if self._manual_toggle_blocked_by_queue(): + logger.warning("Ignoring auto-gain toggle while scan queue is busy.") + return + self.auto_gain_toggle.blockSignals(True) + self.dev.get(CAMERA[0]).auto_gain_enabled.put(enabled) + self.auto_gain_toggle.checked = enabled + self.auto_gain_toggle.blockSignals(False) + + def exposure_time_submitted(self): + self.dev.get(CAMERA[0]).exposure_time.put(self.exposure_time_spin.value()) + @SafeSlot(bool) def opening_shutter(self, enabled: bool): if self._manual_toggle_blocked_by_queue(): -- 2.54.0 From b98bfb3217920c5f38874c432e62aeffd811f892 Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 12:55:38 +0200 Subject: [PATCH 03/15] docs(ids-cameras): mark exposure/gain plan implemented; add HW test config Add a temporary single-camera (ID 41, color) device config for manually verifying the new exposure/auto-gain controls against real hardware outside a live beamtime, and update the plan's status accordingly. --- .../device_configs/test_ids_camera_41.yaml | 26 +++++++++++++++++++ docs/plans/ids-camera-manual-exposure.md | 8 +++--- 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 csaxs_bec/device_configs/test_ids_camera_41.yaml diff --git a/csaxs_bec/device_configs/test_ids_camera_41.yaml b/csaxs_bec/device_configs/test_ids_camera_41.yaml new file mode 100644 index 00000000..d865cd04 --- /dev/null +++ b/csaxs_bec/device_configs/test_ids_camera_41.yaml @@ -0,0 +1,26 @@ +# TEMPORARY test config: a single IDS color camera (camera_id 41), used to +# manually verify the exposure/auto-gain controls added in +# docs/plans/ids-camera-manual-exposure.md against real hardware, without +# reconfiguring a production beamline. Named "cam_xeye" so the OMNY_XRayEye +# widget (which hardcodes that device name) can be pointed at it directly. +# +# Delete this file once real-hardware verification of the exposure/gain +# feature is done -- it is not meant to be loaded in a production session. +cam_xeye: + description: Test IDS color camera (ID 41) for exposure/auto-gain widget verification + deviceClass: csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera + deviceConfig: + camera_id: 41 + bits_per_pixel: 24 + num_rotation_90: 0 + transpose: false + force_monochrome: false + m_n_colormode: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: async + userParameter: + pixel_calibration: 1.0 + deviceTags: + - test_config diff --git a/docs/plans/ids-camera-manual-exposure.md b/docs/plans/ids-camera-manual-exposure.md index fced10cd..ab824f8f 100644 --- a/docs/plans/ids-camera-manual-exposure.md +++ b/docs/plans/ids-camera-manual-exposure.md @@ -1,8 +1,10 @@ # Plan: Manual exposure / auto-gain control for IDSCamera + xrayeye widget knobs -Status: **planned, not yet implemented** (`IDSCamera` and the `OMNY_XRayEye` GUI widget -are both used in production during beamtimes; implementation should happen as its own -change, reviewed and tested outside a live beamtime). +Status: **implemented on `feat/ids-camera-manual-exposure`, pending real-hardware +verification** (`IDSCamera` and the `OMNY_XRayEye` GUI widget are both used in +production during beamtimes; this change should be reviewed and verified against +real hardware outside a live beamtime -- see `csaxs_bec/device_configs/test_ids_camera_41.yaml` +for a single-camera (ID 41, color) config for that purpose -- before merging). ## Context -- 2.54.0 From 8c1c3223935a8fab15507d250959088f16cf2ab8 Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 13:23:38 +0200 Subject: [PATCH 04/15] fix(ids-cameras): fix auto-gain/auto-exposure SDK call, add exposure-range query is_SetAutoParameter's pval1/pval2 are double* (8 bytes); set_auto_gain()/ set_auto_shutter() were passing a c_int (4 bytes), so the driver read garbage past the buffer and rejected it -- this is what raised UEyeException (surfaced as ophyd's "Subscription value callback exception") when toggling auto gain on real hardware. Fixed to pass c_double, matching the SDK's documented signature. Also wrap the three hardware-write subscribe callbacks in try/except so a driver failure logs clearly from IDSCamera instead of only via ophyd's generic subscription-exception message. Adds Camera.get_exposure_range()/get_pixel_clock()/ get_pixel_clock_range()/set_pixel_clock() (the max exposure time is bounded by the current pixel clock) and matching IDSCamera exposure_time_min/max config signals + USER_ACCESS wrappers, seeded on connect, so the GUI can bound its exposure control to real hardware limits instead of a placeholder range. --- .../ids_cameras/base_integration/camera.py | 84 +++++++++++++++++-- csaxs_bec/devices/ids_cameras/ids_camera.py | 75 ++++++++++++++++- csaxs_bec/devices/sim/sim_cameras.py | 17 ++++ tests/tests_devices/test_ids_camera.py | 33 ++++++++ 4 files changed, 198 insertions(+), 11 deletions(-) diff --git a/csaxs_bec/devices/ids_cameras/base_integration/camera.py b/csaxs_bec/devices/ids_cameras/base_integration/camera.py index b28bbb08..d818f362 100644 --- a/csaxs_bec/devices/ids_cameras/base_integration/camera.py +++ b/csaxs_bec/devices/ids_cameras/base_integration/camera.py @@ -235,23 +235,91 @@ class Camera: ) 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() + """Enable or disable auto gain. + + is_SetAutoParameter's pval1/pval2 are `double *` (the SDK reads/writes + 8 bytes through them), not `int *` -- passing a ueye.c_int() here (as + this used to do) hands the driver a 4-byte buffer to read a double + out of, so it reads 4 bytes of adjacent memory as the rest of the + mantissa/exponent. The resulting garbage value is essentially never + exactly 0.0/1.0, so the driver rejects it -- this is what raised + UEyeException here instead of actually toggling auto gain. + """ + enable_value = ueye.c_double(1.0) if enable else ueye.c_double(0.0) check_error( self.ueye.is_SetAutoParameter( - self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_GAIN, enable, value_to_return + self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_GAIN, enable_value, enable_value ), "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() + """Enable or disable auto exposure. See set_auto_gain() for why + pval1/pval2 must be c_double, not c_int.""" + enable_value = ueye.c_double(1.0) if enable else ueye.c_double(0.0) check_error( self.ueye.is_SetAutoParameter( - self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_SHUTTER, enable, value_to_return + self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_SHUTTER, enable_value, enable_value + ), + "IDSCameraObject", + ) + + def get_exposure_range(self) -> tuple[float, float, float]: + """Get the (min, max, increment) exposure time range (ms) at the + camera's current pixel clock. Lowering the pixel clock (see + set_pixel_clock()) raises the achievable max exposure time, at the + cost of frame rate. + """ + param = (ueye.c_double * 3)() + check_error( + self.ueye.is_Exposure( + self.cam.h_cam, + ueye.IS_EXPOSURE_CMD_GET_EXPOSURE_RANGE, + param, + self.ueye.sizeof(param), + ), + "IDSCameraObject", + ) + return float(param[0]), float(param[1]), float(param[2]) + + def get_pixel_clock(self) -> int: + """Get the camera's current pixel clock (MHz).""" + value = ueye.UINT() + check_error( + self.ueye.is_PixelClock( + self.cam.h_cam, ueye.IS_PIXELCLOCK_CMD_GET, value, self.ueye.sizeof(value) + ), + "IDSCameraObject", + ) + return int(value.value) + + def get_pixel_clock_range(self) -> tuple[int, int, int]: + """Get the (min, max, increment) pixel clock range (MHz).""" + param = (ueye.UINT * 3)() + check_error( + self.ueye.is_PixelClock( + self.cam.h_cam, + ueye.IS_PIXELCLOCK_CMD_GET_RANGE, + param, + self.ueye.sizeof(param), + ), + "IDSCameraObject", + ) + return int(param[0]), int(param[1]), int(param[2]) + + def set_pixel_clock(self, value: int) -> None: + """Set the camera's pixel clock (MHz). Lowering it raises the max + achievable exposure time (see get_exposure_range()), at the cost of + frame rate -- there is no GUI control for this yet, it's a script- + level knob for cases where the default pixel clock's exposure + ceiling is too low.""" + pixel_clock = ueye.UINT(value) + check_error( + self.ueye.is_PixelClock( + self.cam.h_cam, + ueye.IS_PIXELCLOCK_CMD_SET, + pixel_clock, + self.ueye.sizeof(pixel_clock), ), "IDSCameraObject", ) diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py index 8c692318..0a48cf05 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera.py @@ -84,6 +84,20 @@ class IDSCamera(PSIDeviceBase): doc="Enable/disable auto-gain.", kind=Kind.config, ) + exposure_time_min = Cpt( + Signal, + name="exposure_time_min", + value=0.0, + doc="Minimum exposure time (ms) at the camera's current pixel clock.", + kind=Kind.config, + ) + exposure_time_max = Cpt( + Signal, + name="exposure_time_max", + value=1000.0, + doc="Maximum exposure time (ms) at the camera's current pixel clock.", + kind=Kind.config, + ) USER_ACCESS = [ "start_live_mode", @@ -98,6 +112,10 @@ class IDSCamera(PSIDeviceBase): "set_exposure_time", "set_auto_exposure_enabled", "set_auto_gain_enabled", + "get_exposure_time_range", + "get_pixel_clock", + "get_pixel_clock_range", + "set_pixel_clock", ] def __init__( @@ -309,13 +327,35 @@ class IDSCamera(PSIDeviceBase): ############## Exposure / Gain ############## def _on_exposure_time_changed(self, *args, value, **kwargs): - self.cam.exposure_time = value + try: + self.cam.exposure_time = value + except Exception: + # Caught (not re-raised) so this doesn't surface as ophyd's generic + # "Subscription value callback exception" -- which logs the same + # traceback but without this context. The Signal itself already + # holds `value` regardless (ophyd updates the cache before running + # subscribers), so it now reflects the *requested*, not confirmed, + # exposure time if the hardware write failed -- re-set it (e.g. via + # set_exposure_time()) once the underlying issue is resolved. + logger.exception( + f"{self.name}: failed to set exposure_time={value} on hardware." + ) def _on_auto_exposure_enabled_changed(self, *args, value, **kwargs): - self.cam.set_auto_shutter(bool(value)) + try: + self.cam.set_auto_shutter(bool(value)) + except Exception: + logger.exception( + f"{self.name}: failed to set auto_exposure_enabled={bool(value)} on hardware." + ) def _on_auto_gain_enabled_changed(self, *args, value, **kwargs): - self.cam.set_auto_gain(bool(value)) + try: + self.cam.set_auto_gain(bool(value)) + except Exception: + logger.exception( + f"{self.name}: failed to set auto_gain_enabled={bool(value)} on hardware." + ) def get_exposure_time(self) -> float: """Get the current exposure time (ms), from the cached Signal value.""" @@ -333,6 +373,29 @@ class IDSCamera(PSIDeviceBase): def set_auto_gain_enabled(self, enable: bool) -> None: self.auto_gain_enabled.put(bool(enable)) + def get_exposure_time_range(self) -> tuple[float, float]: + """Get the (min, max) exposure time (ms) at the camera's current + pixel clock, from the cached Signal values (seeded on connect; see + on_connected()).""" + return float(self.exposure_time_min.get()), float(self.exposure_time_max.get()) + + def get_pixel_clock(self) -> int: + """Get the camera's current pixel clock (MHz).""" + return self.cam.get_pixel_clock() + + def get_pixel_clock_range(self) -> tuple[int, int, int]: + """Get the (min, max, increment) pixel clock range (MHz).""" + return self.cam.get_pixel_clock_range() + + def set_pixel_clock(self, value: int) -> None: + """Set the pixel clock (MHz). Lowering it raises the max achievable + exposure time (get_exposure_time_range()), at the cost of frame + rate. Re-seeds exposure_time_min/max from the new range.""" + self.cam.set_pixel_clock(value) + exp_min, exp_max, _inc = self.cam.get_exposure_range() + self.exposure_time_min.put(exp_min) + self.exposure_time_max.put(exp_max) + ############## User Interface Methods ############## def on_connected(self): @@ -349,6 +412,12 @@ class IDSCamera(PSIDeviceBase): # driver (only enable-setters), so they start at their declared default # (True) and only reflect reality once explicitly set through this API. self.exposure_time.put(self.cam.exposure_time) + # Seed the exposure time range (depends on the current pixel clock) so + # the GUI can bound its slider to values the hardware will actually + # accept, instead of a generic placeholder. + exp_min, exp_max, _inc = self.cam.get_exposure_range() + self.exposure_time_min.put(exp_min) + self.exposure_time_max.put(exp_max) self.set_rect_roi(0, 0, self.cam.cam.width.value, self.cam.cam.height.value) def on_destroy(self): diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index c5cd44f4..8ca3ecf2 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -241,6 +241,8 @@ class _SimIDSBackend: self._exposure_time = 10000.0 # ms self._auto_exposure = True self._auto_gain = True + self._pixel_clock = 20 # MHz + self._pixel_clock_range = (5, 40, 1) # (min, max, increment) MHz self._width = width self._height = height self._rotation_coupling = rotation_coupling @@ -299,6 +301,21 @@ class _SimIDSBackend: def set_auto_gain(self, enable: bool): self._auto_gain = bool(enable) + def get_exposure_range(self) -> tuple[float, float, float]: + # Loosely mimics the real driver's pixel-clock-dependent ceiling: a + # higher pixel clock -> shorter max frame time -> lower max exposure. + max_exposure = 1_000_000.0 / max(self._pixel_clock, 1) + return 0.1, max_exposure, 0.1 + + def get_pixel_clock(self) -> int: + return self._pixel_clock + + def get_pixel_clock_range(self) -> tuple[int, int, int]: + return self._pixel_clock_range + + def set_pixel_clock(self, value: int): + self._pixel_clock = int(value) + def _current_angle_deg(self) -> float: coupling = self._rotation_coupling galil = SimStateRegistry.get( diff --git a/tests/tests_devices/test_ids_camera.py b/tests/tests_devices/test_ids_camera.py index 1587f0a8..9bc63fa2 100644 --- a/tests/tests_devices/test_ids_camera.py +++ b/tests/tests_devices/test_ids_camera.py @@ -27,6 +27,7 @@ def ids_camera(): camera.cam.cam = mock.Mock() camera.cam.cam.width.value = 2 camera.cam.cam.height.value = 2 + camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 1000.0, 0.1)) yield camera camera.stop_live_mode() @@ -167,10 +168,42 @@ def test_set_auto_gain_enabled(ids_camera): def test_on_connected_seeds_exposure_time(ids_camera): ids_camera.cam.on_connect = mock.Mock() ids_camera.cam.exposure_time = 4200.0 + ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 1000.0, 0.1)) ids_camera.on_connected() assert ids_camera.get_exposure_time() == 4200.0 +def test_on_connected_seeds_exposure_time_range(ids_camera): + ids_camera.cam.on_connect = mock.Mock() + ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.05, 500.0, 0.05)) + ids_camera.on_connected() + assert ids_camera.get_exposure_time_range() == (0.05, 500.0) + + +def test_auto_gain_hardware_failure_is_logged_not_raised(ids_camera): + """A driver-level failure inside the subscribe callback must not + propagate -- see _on_auto_gain_enabled_changed()'s docstring. This also + covers the case that motivated it: the old set_auto_gain() passed a + c_int where the SDK expects c_double, which the real driver rejected.""" + ids_camera.cam.set_auto_gain = mock.Mock(side_effect=RuntimeError("driver rejected value")) + ids_camera.set_auto_gain_enabled(False) # must not raise + ids_camera.cam.set_auto_gain.assert_called_once_with(False) + + +def test_auto_exposure_hardware_failure_is_logged_not_raised(ids_camera): + ids_camera.cam.set_auto_shutter = mock.Mock(side_effect=RuntimeError("driver rejected value")) + ids_camera.set_auto_exposure_enabled(False) # must not raise + ids_camera.cam.set_auto_shutter.assert_called_once_with(False) + + +def test_set_pixel_clock_reseeds_exposure_range(ids_camera): + ids_camera.cam.set_pixel_clock = mock.Mock() + ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.2, 200.0, 0.1)) + ids_camera.set_pixel_clock(10) + ids_camera.cam.set_pixel_clock.assert_called_once_with(10) + assert ids_camera.get_exposure_time_range() == (0.2, 200.0) + + def test_push_smear_preview_no_rotation_compensation(ids_camera): """smear_preview has no rotation_90/transpose configured, so pushed data passes straight through unmodified -- unlike push_preview_image, no -- 2.54.0 From 557ca42a0c61d10ec650ff3c3b13e62bbb3bf185 Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 13:23:45 +0200 Subject: [PATCH 05/15] feat(xrayeye): reorganize control panel; exposure time as slider, not spinbox Moves the auto-exposure/auto-gain toggles out of the shutter/camera switch grid into their own section, bracketed by horizontal separators: switches -> line -> exposure/gain section -> line -> alignment values (2D positioner + zoom). Also replaces the exposure-time QDoubleSpinBox with a QSlider bounded to the device's real exposure_time_min/max (from IDSCamera, seeded from hardware) instead of a hardcoded 0.01-1000ms placeholder -- QSlider is int-only, so the widget tracks tenths of a ms internally and shows one decimal on a companion label. Submission still fires once on release, not per tick. --- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 104 +++++++++++++----- 1 file changed, 77 insertions(+), 27 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index a9c57ed7..7d44b807 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -13,7 +13,6 @@ from bec_widgets.widgets.plots.roi.image_roi import BaseROI, CircularROI, Rectan from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( - QDoubleSpinBox, QFrame, QGridLayout, QHBoxLayout, @@ -21,6 +20,7 @@ from qtpy.QtWidgets import ( QLineEdit, QPushButton, QSizePolicy, + QSlider, QSpinBox, QToolButton, QVBoxLayout, @@ -270,6 +270,10 @@ class OMNY_XRayEye(BECWidget, QWidget): PIXEL_CALIBRATION_USER_PARAM = "pixel_calibration" PIXEL_CALIBRATION_DEFAULT = 1.0 + # exposure_time_slider works in tenths of a ms internally (QSlider is + # int-only); divide by this to get ms. + _EXPOSURE_SLIDER_SCALE = 10 + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self._live_view_signal = CAMERA[1] @@ -403,8 +407,25 @@ class OMNY_XRayEye(BECWidget, QWidget): switch_grid.addWidget(self.smear_preview_label, 1, 3, _right_vcenter) switch_grid.addWidget(self.smear_preview_toggle, 1, 4, Qt.AlignmentFlag.AlignVCenter) - # Exposure/gain control knobs (row 2): auto-exposure and auto-gain - # enable toggles, mirroring the shutter/camera-running row above. + self.control_panel_layout.addWidget(self.switch_grid_widget) + + # separator: below shutter/camera-running/smear switches, above the + # exposure/gain section + self.control_panel_layout.addWidget(self._create_separator()) + + # Exposure/gain section: auto-exposure and auto-gain enable toggles + # (row 0, same layout idiom as switch_grid above), plus a manual + # exposure-time slider (row 1) that's only enabled while + # auto-exposure is off. Its range is a placeholder until seeded from + # the hardware-reported (min, max) at connect time -- see + # getting_camera_status(). + self.exposure_grid_widget = QWidget(parent=self) + exposure_grid = QGridLayout(self.exposure_grid_widget) + exposure_grid.setContentsMargins(0, 0, 0, 0) + exposure_grid.setHorizontalSpacing(8) + exposure_grid.setVerticalSpacing(4) + exposure_grid.setColumnStretch(0, 1) + self.auto_exposure_label = QLabel("Auto exposure", parent=self) self.auto_exposure_toggle = ToggleSwitch(parent=self) self.auto_exposure_toggle.checked = True @@ -415,14 +436,35 @@ class OMNY_XRayEye(BECWidget, QWidget): self.auto_gain_toggle.checked = True self.auto_gain_toggle.enabled.connect(self.auto_gain_enabled_changed) - switch_grid.addWidget(self.auto_exposure_label, 2, 1, _right_vcenter) - switch_grid.addWidget(self.auto_exposure_toggle, 2, 2, Qt.AlignmentFlag.AlignVCenter) - switch_grid.addWidget(self.auto_gain_label, 2, 3, _right_vcenter) - switch_grid.addWidget(self.auto_gain_toggle, 2, 4, Qt.AlignmentFlag.AlignVCenter) + exposure_grid.addWidget(self.auto_exposure_label, 0, 1, _right_vcenter) + exposure_grid.addWidget(self.auto_exposure_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter) + exposure_grid.addWidget(self.auto_gain_label, 0, 3, _right_vcenter) + exposure_grid.addWidget(self.auto_gain_toggle, 0, 4, Qt.AlignmentFlag.AlignVCenter) - self.control_panel_layout.addWidget(self.switch_grid_widget) + # QSlider only takes ints, so exposure time is tracked in tenths of a + # ms internally (_EXPOSURE_SLIDER_SCALE) for one decimal of + # resolution on the display label. + self.exposure_time_label = QLabel("Exposure time", parent=self) + self.exposure_time_slider = QSlider(Qt.Orientation.Horizontal, parent=self) + self.exposure_time_slider.setRange(0, 10000) # placeholder; reseeded on connect + self.exposure_time_slider.setEnabled(False) # auto-exposure starts enabled + self.exposure_time_value_label = QLabel("-- ms", parent=self) + self.exposure_time_value_label.setMinimumWidth(60) + self.exposure_time_slider.valueChanged.connect(self._update_exposure_time_value_label) + # sliderReleased (fires once, on mouse-up), not valueChanged (fires on + # every tick while dragging) -- the throttling choice here, same + # "don't hammer the device" concern as editingFinished was for the + # spinbox this replaces. + self.exposure_time_slider.sliderReleased.connect(self.exposure_time_submitted) - # separator + exposure_grid.addWidget(self.exposure_time_label, 1, 1, _right_vcenter) + exposure_grid.addWidget(self.exposure_time_slider, 1, 2, 1, 2) + exposure_grid.addWidget(self.exposure_time_value_label, 1, 4, Qt.AlignmentFlag.AlignVCenter) + + self.control_panel_layout.addWidget(self.exposure_grid_widget) + + # separator: below the exposure/gain section, above the alignment + # values (2D positioner + zoom) self.control_panel_layout.addWidget(self._create_separator()) # 2D Positioner (fixed size) @@ -457,18 +499,6 @@ class OMNY_XRayEye(BECWidget, QWidget): # Add form to control panel self.control_panel_layout.addLayout(step_size_form) - # Exposure time (manual entry, only usable while auto-exposure is off) - exposure_form = QGridLayout() - self.exposure_time_spin = QDoubleSpinBox(parent=self) - self.exposure_time_spin.setRange(0.01, 1000.0) # ms; confirm real driver range - self.exposure_time_spin.setDecimals(2) - self.exposure_time_spin.setSuffix(" ms") - self.exposure_time_spin.setEnabled(False) # auto-exposure starts enabled - self.exposure_time_spin.editingFinished.connect(self.exposure_time_submitted) - exposure_form.addWidget(QLabel("Exposure time", parent=self), 0, 0) - exposure_form.addWidget(self.exposure_time_spin, 0, 1) - self.control_panel_layout.addLayout(exposure_form) - # Push form to bottom self.control_panel_layout.addStretch() @@ -934,7 +964,7 @@ class OMNY_XRayEye(BECWidget, QWidget): enabled = bool(auto_exp.get("value")) self.auto_exposure_toggle.blockSignals(True) self.auto_exposure_toggle.checked = enabled - self.exposure_time_spin.setEnabled(not enabled) + self.exposure_time_slider.setEnabled(not enabled) self.auto_exposure_toggle.blockSignals(False) auto_gain = signals.get(f"{CAMERA[0]}_auto_gain_enabled") @@ -943,11 +973,25 @@ class OMNY_XRayEye(BECWidget, QWidget): self.auto_gain_toggle.checked = bool(auto_gain.get("value")) self.auto_gain_toggle.blockSignals(False) + # Reseed the slider's bounds first (rare -- only changes if the pixel + # clock changed), so a value arriving in the same message is clamped + # against up-to-date min/max rather than a stale range. + exposure_min = signals.get(f"{CAMERA[0]}_exposure_time_min") + exposure_max = signals.get(f"{CAMERA[0]}_exposure_time_max") + if exposure_min is not None and exposure_max is not None: + lo = round(float(exposure_min.get("value")) * self._EXPOSURE_SLIDER_SCALE) + hi = round(float(exposure_max.get("value")) * self._EXPOSURE_SLIDER_SCALE) + if (lo, hi) != (self.exposure_time_slider.minimum(), self.exposure_time_slider.maximum()): + self.exposure_time_slider.setRange(lo, hi) + exposure_time = signals.get(f"{CAMERA[0]}_exposure_time") if exposure_time is not None: - self.exposure_time_spin.blockSignals(True) - self.exposure_time_spin.setValue(float(exposure_time.get("value"))) - self.exposure_time_spin.blockSignals(False) + self.exposure_time_slider.blockSignals(True) + self.exposure_time_slider.setValue( + round(float(exposure_time.get("value")) * self._EXPOSURE_SLIDER_SCALE) + ) + self.exposure_time_slider.blockSignals(False) + self._update_exposure_time_value_label(self.exposure_time_slider.value()) @SafeSlot(bool) def auto_exposure_enabled_changed(self, enabled: bool): @@ -957,7 +1001,7 @@ class OMNY_XRayEye(BECWidget, QWidget): self.auto_exposure_toggle.blockSignals(True) self.dev.get(CAMERA[0]).auto_exposure_enabled.put(enabled) self.auto_exposure_toggle.checked = enabled - self.exposure_time_spin.setEnabled(not enabled) + self.exposure_time_slider.setEnabled(not enabled) self.auto_exposure_toggle.blockSignals(False) @SafeSlot(bool) @@ -970,8 +1014,14 @@ class OMNY_XRayEye(BECWidget, QWidget): self.auto_gain_toggle.checked = enabled self.auto_gain_toggle.blockSignals(False) + def _update_exposure_time_value_label(self, raw_value: int): + self.exposure_time_value_label.setText( + f"{raw_value / self._EXPOSURE_SLIDER_SCALE:.1f} ms" + ) + def exposure_time_submitted(self): - self.dev.get(CAMERA[0]).exposure_time.put(self.exposure_time_spin.value()) + value_ms = self.exposure_time_slider.value() / self._EXPOSURE_SLIDER_SCALE + self.dev.get(CAMERA[0]).exposure_time.put(value_ms) @SafeSlot(bool) def opening_shutter(self, enabled: bool): -- 2.54.0 From 93e18af4d505651c3c30c594ce02547483d9fa25 Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 13:23:50 +0200 Subject: [PATCH 06/15] docs(ids-cameras): record HW-testing findings in the exposure/gain plan --- docs/plans/ids-camera-manual-exposure.md | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/plans/ids-camera-manual-exposure.md b/docs/plans/ids-camera-manual-exposure.md index ab824f8f..697e3e35 100644 --- a/docs/plans/ids-camera-manual-exposure.md +++ b/docs/plans/ids-camera-manual-exposure.md @@ -330,3 +330,36 @@ The `is not None` guards are defensive (harmless if this ever runs against an ol xrayeye widget against a connected `cam_xeye`, toggle auto-exposure off, set an exposure time, and confirm the live image brightness responds and stays stable (doesn't drift back, confirming auto-exposure is actually off). + +## Addendum: HW-testing findings (2026-09-14) + +Real-hardware testing against `test_ids_camera_41.yaml` surfaced one bug and two +follow-up improvements, all now implemented: + +- **Bug**: toggling "Auto gain" raised `ophyd.ophydobj | [ERROR] | Subscription value + callback exception` from the device server. Root cause: `Camera.set_auto_gain()` / + `set_auto_shutter()` (`base_integration/camera.py`) passed `ueye.c_int()` for + `is_SetAutoParameter`'s `pval1`/`pval2`, which the SDK defines as `double *` (8 bytes) + — the driver read 4 bytes past a `c_int` (4 bytes) as the rest of the double, got a + near-never-exactly-0.0/1.0 garbage value, and rejected it. Fixed by passing + `ueye.c_double(1.0/0.0)` instead, matching upstream pyueye examples. Also wrapped all + three hardware-write callbacks (`_on_exposure_time_changed`, + `_on_auto_exposure_enabled_changed`, `_on_auto_gain_enabled_changed`) in try/except so + a *future* driver-level failure logs clearly from `IDSCamera` itself instead of + surfacing only as ophyd's generic subscription-exception message. +- **Exposure ceiling**: the max settable exposure time is bounded by the camera's + current pixel clock (lower pixel clock -> longer max exposure, less frame rate). + Added `Camera.get_exposure_range()` (`IS_EXPOSURE_CMD_GET_EXPOSURE_RANGE`) and + `get_pixel_clock()`/`get_pixel_clock_range()`/`set_pixel_clock()` + (`IS_PIXELCLOCK_CMD_GET`/`_GET_RANGE`/`_SET`) to `base_integration/camera.py`, exposed + on `IDSCamera` as `get_exposure_time_range()`/`get_pixel_clock()`/ + `get_pixel_clock_range()`/`set_pixel_clock()` (`USER_ACCESS`, script-level for now — + no GUI pixel-clock control yet). New `exposure_time_min`/`exposure_time_max` + `Kind.config` signals are seeded in `on_connected()` and re-seeded by + `set_pixel_clock()`. +- **Widget**: replaced the exposure-time spinbox with a `QSlider` bounded to + `exposure_time_min`/`max` (seeded from the device_read_configuration message, same as + everything else — still no polling), and reorganized the control panel: a horizontal + separator below the shutter/camera-running/smear switches, then the exposure/gain + section (auto-exposure toggle, auto-gain toggle, exposure slider), then another + separator, then the alignment values (2D positioner + zoom) below. -- 2.54.0 From 9a999bed37cee0c86b8fc556f1670f68ee18af0d Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 13:36:38 +0200 Subject: [PATCH 07/15] fix(ids-cameras): apply auto-exposure/gain defaults on connect; add pixel clock auto_exposure_enabled/auto_gain_enabled default to True but were never actually written to hardware at connect -- is_ResetToDefault() leaves the sensor's auto-shutter/auto-gain off, so a fresh session ran under-exposed until an operator happened to re-toggle the GUI switch (which is what issued the enable call for the first time). Fixed by round-tripping both through their subscribe callbacks in on_connected(), same idiom as exposure_time. Also adds pixel_clock/pixel_clock_min/pixel_clock_max Kind.config signals, backed by the Camera.get_pixel_clock()/get_pixel_clock_range()/ set_pixel_clock() added earlier -- lowering the pixel clock raises the achievable max exposure time, and changing it now re-derives exposure_time_min/max from hardware automatically. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PYQTHuSxSaLCijvayoRkX5 --- csaxs_bec/devices/ids_cameras/ids_camera.py | 78 +++++++++++++++++---- tests/tests_devices/test_ids_camera.py | 24 +++++++ 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py index 0a48cf05..e181e59c 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera.py @@ -98,6 +98,22 @@ class IDSCamera(PSIDeviceBase): doc="Maximum exposure time (ms) at the camera's current pixel clock.", kind=Kind.config, ) + pixel_clock = Cpt( + Signal, + name="pixel_clock", + value=0, + doc=( + "Camera pixel clock (MHz). Lowering it raises the max achievable " + "exposure time (see exposure_time_max), at the cost of frame rate." + ), + kind=Kind.config, + ) + pixel_clock_min = Cpt( + Signal, name="pixel_clock_min", value=0, doc="Minimum pixel clock (MHz).", kind=Kind.config + ) + pixel_clock_max = Cpt( + Signal, name="pixel_clock_max", value=0, doc="Maximum pixel clock (MHz).", kind=Kind.config + ) USER_ACCESS = [ "start_live_mode", @@ -171,6 +187,7 @@ class IDSCamera(PSIDeviceBase): self.exposure_time.subscribe(self._on_exposure_time_changed, run=False) self.auto_exposure_enabled.subscribe(self._on_auto_exposure_enabled_changed, run=False) self.auto_gain_enabled.subscribe(self._on_auto_gain_enabled_changed, run=False) + self.pixel_clock.subscribe(self._on_pixel_clock_changed, run=False) self.live_mode_enabled.put(bool(live_mode)) ############## Live Mode Methods ############## @@ -357,6 +374,18 @@ class IDSCamera(PSIDeviceBase): f"{self.name}: failed to set auto_gain_enabled={bool(value)} on hardware." ) + def _on_pixel_clock_changed(self, *args, value, **kwargs): + try: + self.cam.set_pixel_clock(int(value)) + # The exposure range is pixel-clock-dependent -- re-derive it from + # hardware immediately so exposure_time_min/max (and the GUI + # slider bound to them) never lag behind the new pixel clock. + exp_min, exp_max, _inc = self.cam.get_exposure_range() + self.exposure_time_min.put(exp_min) + self.exposure_time_max.put(exp_max) + except Exception: + logger.exception(f"{self.name}: failed to set pixel_clock={int(value)} on hardware.") + def get_exposure_time(self) -> float: """Get the current exposure time (ms), from the cached Signal value.""" return float(self.exposure_time.get()) @@ -380,21 +409,20 @@ class IDSCamera(PSIDeviceBase): return float(self.exposure_time_min.get()), float(self.exposure_time_max.get()) def get_pixel_clock(self) -> int: - """Get the camera's current pixel clock (MHz).""" - return self.cam.get_pixel_clock() + """Get the camera's current pixel clock (MHz), from the cached Signal + value.""" + return int(self.pixel_clock.get()) - def get_pixel_clock_range(self) -> tuple[int, int, int]: - """Get the (min, max, increment) pixel clock range (MHz).""" - return self.cam.get_pixel_clock_range() + def get_pixel_clock_range(self) -> tuple[int, int]: + """Get the (min, max) pixel clock (MHz), from the cached Signal + values (seeded on connect; see on_connected()).""" + return int(self.pixel_clock_min.get()), int(self.pixel_clock_max.get()) def set_pixel_clock(self, value: int) -> None: """Set the pixel clock (MHz). Lowering it raises the max achievable - exposure time (get_exposure_time_range()), at the cost of frame - rate. Re-seeds exposure_time_min/max from the new range.""" - self.cam.set_pixel_clock(value) - exp_min, exp_max, _inc = self.cam.get_exposure_range() - self.exposure_time_min.put(exp_min) - self.exposure_time_max.put(exp_max) + exposure time (see get_exposure_time_range()), at the cost of frame + rate.""" + self.pixel_clock.put(int(value)) ############## User Interface Methods ############## @@ -403,14 +431,25 @@ class IDSCamera(PSIDeviceBase): self.cam.force_monochrome = self._force_monochrome self.cam.on_connect() self.live_mode_enabled.put(bool(self._inputs.get("live_mode", False))) + # auto_exposure_enabled/auto_gain_enabled default to True, but there is + # no SDK query for the camera's *current* auto-shutter/auto-gain state + # (only enable-setters) -- so unlike live_mode_enabled/exposure_time, + # this used to just leave the Signal at its declared default without + # ever telling the hardware. is_ResetToDefault() (called during + # IDSCameraObject init, i.e. every connect) leaves the sensor with + # auto-shutter/auto-gain off, so a fresh session started under-exposed + # until an operator happened to toggle the GUI switch off and back on + # -- which is what actually issued the enable call for the first + # time. Round-trip both through their subscribe callbacks now (same + # idiom as exposure_time below) so the declared default is actually + # enforced on hardware on every connect, not just once some operator + # notices and re-toggles it. + self.auto_exposure_enabled.put(bool(self.auto_exposure_enabled.get())) + self.auto_gain_enabled.put(bool(self.auto_gain_enabled.get())) # Seed exposure_time from the real hardware value once, so the GUI shows # a real number immediately on connect instead of the 0.0 placeholder. # This round-trips through _on_exposure_time_changed, which writes the # same value back to the driver -- a harmless one-time no-op write. - # auto_exposure_enabled/auto_gain_enabled are not seeded from hardware -- - # there is no SDK query for current auto-shutter/auto-gain state in this - # driver (only enable-setters), so they start at their declared default - # (True) and only reflect reality once explicitly set through this API. self.exposure_time.put(self.cam.exposure_time) # Seed the exposure time range (depends on the current pixel clock) so # the GUI can bound its slider to values the hardware will actually @@ -418,6 +457,15 @@ class IDSCamera(PSIDeviceBase): exp_min, exp_max, _inc = self.cam.get_exposure_range() self.exposure_time_min.put(exp_min) self.exposure_time_max.put(exp_max) + # Seed the pixel clock and its range the same way. This round-trips + # through _on_pixel_clock_changed, which re-derives exposure_time_min/ + # max from hardware again -- redundant with the lines just above on + # this first call, but it's what keeps them correct after any later + # pixel-clock change from the GUI. + pc_min, pc_max, _pc_inc = self.cam.get_pixel_clock_range() + self.pixel_clock_min.put(pc_min) + self.pixel_clock_max.put(pc_max) + self.pixel_clock.put(self.cam.get_pixel_clock()) self.set_rect_roi(0, 0, self.cam.cam.width.value, self.cam.cam.height.value) def on_destroy(self): diff --git a/tests/tests_devices/test_ids_camera.py b/tests/tests_devices/test_ids_camera.py index 9bc63fa2..2ad8e104 100644 --- a/tests/tests_devices/test_ids_camera.py +++ b/tests/tests_devices/test_ids_camera.py @@ -28,6 +28,8 @@ def ids_camera(): camera.cam.cam.width.value = 2 camera.cam.cam.height.value = 2 camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 1000.0, 0.1)) + camera.cam.get_pixel_clock = mock.Mock(return_value=20) + camera.cam.get_pixel_clock_range = mock.Mock(return_value=(5, 40, 1)) yield camera camera.stop_live_mode() @@ -196,6 +198,28 @@ def test_auto_exposure_hardware_failure_is_logged_not_raised(ids_camera): ids_camera.cam.set_auto_shutter.assert_called_once_with(False) +def test_on_connected_applies_auto_exposure_and_gain_defaults_to_hardware(ids_camera): + """auto_exposure_enabled/auto_gain_enabled default to True but, unlike + live_mode_enabled/exposure_time, were never actually applied to hardware + at connect -- meaning a fresh session left the camera in whatever + is_ResetToDefault() leaves it in (observed: auto off) regardless of the + Signal's declared default. on_connected() must now round-trip both + through their subscribe callbacks so the real SDK call is issued.""" + ids_camera.cam.on_connect = mock.Mock() + ids_camera.on_connected() + ids_camera.cam.set_auto_shutter.assert_called_once_with(True) + ids_camera.cam.set_auto_gain.assert_called_once_with(True) + + +def test_on_connected_seeds_pixel_clock(ids_camera): + ids_camera.cam.on_connect = mock.Mock() + ids_camera.cam.get_pixel_clock = mock.Mock(return_value=30) + ids_camera.cam.get_pixel_clock_range = mock.Mock(return_value=(5, 45, 1)) + ids_camera.on_connected() + assert ids_camera.get_pixel_clock() == 30 + assert ids_camera.get_pixel_clock_range() == (5, 45) + + def test_set_pixel_clock_reseeds_exposure_range(ids_camera): ids_camera.cam.set_pixel_clock = mock.Mock() ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.2, 200.0, 0.1)) -- 2.54.0 From 69ad5135a917cfcae5b7d098f84f50dde444774a Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 13:36:46 +0200 Subject: [PATCH 08/15] feat(xrayeye): add pixel clock slider; fix exposure slider's physical bound Adds a "Pixel clock" slider (MHz) below the exposure-time slider, bounded to the device's pixel_clock_min/max -- previously that control was script-only via IDSCamera.set_pixel_clock(). Needed because the exposure slider's max is bounded by the current pixel clock, and operators hitting that ceiling need a way to raise it from the GUI. Also fixes the exposure-time slider allowing a literal 0 ms: its bounds were rounded to nearest tenth-of-a-ms, which could round a small nonzero hardware minimum down to 0. Now rounds the min up (ceil, clamped to >= 1) and the max down (floor) instead, so the slider never claims a bound the hardware won't actually accept. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PYQTHuSxSaLCijvayoRkX5 --- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 7d44b807..bed4e387 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -1,5 +1,7 @@ from __future__ import annotations +import math + import pyqtgraph as pg from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints @@ -446,7 +448,10 @@ class OMNY_XRayEye(BECWidget, QWidget): # resolution on the display label. self.exposure_time_label = QLabel("Exposure time", parent=self) self.exposure_time_slider = QSlider(Qt.Orientation.Horizontal, parent=self) - self.exposure_time_slider.setRange(0, 10000) # placeholder; reseeded on connect + # Placeholder until reseeded from hardware on connect (see + # getting_camera_status()) -- minimum is 1 (0.1 ms), never 0, since + # 0 ms exposure isn't physical. + self.exposure_time_slider.setRange(1, 10000) self.exposure_time_slider.setEnabled(False) # auto-exposure starts enabled self.exposure_time_value_label = QLabel("-- ms", parent=self) self.exposure_time_value_label.setMinimumWidth(60) @@ -461,6 +466,21 @@ class OMNY_XRayEye(BECWidget, QWidget): exposure_grid.addWidget(self.exposure_time_slider, 1, 2, 1, 2) exposure_grid.addWidget(self.exposure_time_value_label, 1, 4, Qt.AlignmentFlag.AlignVCenter) + # Pixel clock (MHz): lowering it raises the max achievable exposure + # time (at the cost of frame rate) -- exposed here since operators + # hitting the exposure slider's ceiling need this knob to go further. + self.pixel_clock_label = QLabel("Pixel clock", parent=self) + self.pixel_clock_slider = QSlider(Qt.Orientation.Horizontal, parent=self) + self.pixel_clock_slider.setRange(1, 100) # MHz; placeholder, reseeded on connect + self.pixel_clock_value_label = QLabel("-- MHz", parent=self) + self.pixel_clock_value_label.setMinimumWidth(60) + self.pixel_clock_slider.valueChanged.connect(self._update_pixel_clock_value_label) + self.pixel_clock_slider.sliderReleased.connect(self.pixel_clock_submitted) + + exposure_grid.addWidget(self.pixel_clock_label, 2, 1, _right_vcenter) + exposure_grid.addWidget(self.pixel_clock_slider, 2, 2, 1, 2) + exposure_grid.addWidget(self.pixel_clock_value_label, 2, 4, Qt.AlignmentFlag.AlignVCenter) + self.control_panel_layout.addWidget(self.exposure_grid_widget) # separator: below the exposure/gain section, above the alignment @@ -973,14 +993,36 @@ class OMNY_XRayEye(BECWidget, QWidget): self.auto_gain_toggle.checked = bool(auto_gain.get("value")) self.auto_gain_toggle.blockSignals(False) - # Reseed the slider's bounds first (rare -- only changes if the pixel - # clock changed), so a value arriving in the same message is clamped - # against up-to-date min/max rather than a stale range. + # Reseed the pixel-clock slider's bounds/value before the exposure + # ones below -- changing the pixel clock changes the exposure range, + # and both arrive together in this same message. + pixel_clock_min = signals.get(f"{CAMERA[0]}_pixel_clock_min") + pixel_clock_max = signals.get(f"{CAMERA[0]}_pixel_clock_max") + if pixel_clock_min is not None and pixel_clock_max is not None: + lo = int(pixel_clock_min.get("value")) + hi = int(pixel_clock_max.get("value")) + if (lo, hi) != (self.pixel_clock_slider.minimum(), self.pixel_clock_slider.maximum()): + self.pixel_clock_slider.setRange(lo, hi) + + pixel_clock = signals.get(f"{CAMERA[0]}_pixel_clock") + if pixel_clock is not None: + self.pixel_clock_slider.blockSignals(True) + self.pixel_clock_slider.setValue(int(pixel_clock.get("value"))) + self.pixel_clock_slider.blockSignals(False) + self._update_pixel_clock_value_label(self.pixel_clock_slider.value()) + + # Reseed the exposure slider's bounds next (rare -- only changes if + # the pixel clock changed), so a value arriving in the same message + # is clamped against up-to-date min/max rather than a stale range. + # Round the min UP and the max DOWN (never the other way) -- e.g. a + # hardware min of 0.04 ms rounds to 0 in tenths-of-ms units, which + # would let the slider claim 0 ms exposure is settable when it isn't; + # ceil (clamped to at least 1) keeps the bound physical instead. exposure_min = signals.get(f"{CAMERA[0]}_exposure_time_min") exposure_max = signals.get(f"{CAMERA[0]}_exposure_time_max") if exposure_min is not None and exposure_max is not None: - lo = round(float(exposure_min.get("value")) * self._EXPOSURE_SLIDER_SCALE) - hi = round(float(exposure_max.get("value")) * self._EXPOSURE_SLIDER_SCALE) + lo = max(1, math.ceil(float(exposure_min.get("value")) * self._EXPOSURE_SLIDER_SCALE)) + hi = math.floor(float(exposure_max.get("value")) * self._EXPOSURE_SLIDER_SCALE) if (lo, hi) != (self.exposure_time_slider.minimum(), self.exposure_time_slider.maximum()): self.exposure_time_slider.setRange(lo, hi) @@ -1023,6 +1065,12 @@ class OMNY_XRayEye(BECWidget, QWidget): value_ms = self.exposure_time_slider.value() / self._EXPOSURE_SLIDER_SCALE self.dev.get(CAMERA[0]).exposure_time.put(value_ms) + def _update_pixel_clock_value_label(self, value: int): + self.pixel_clock_value_label.setText(f"{value} MHz") + + def pixel_clock_submitted(self): + self.dev.get(CAMERA[0]).pixel_clock.put(self.pixel_clock_slider.value()) + @SafeSlot(bool) def opening_shutter(self, enabled: bool): if self._manual_toggle_blocked_by_queue(): -- 2.54.0 From b01448f161f94b6138b66558edbe29b875a07020 Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 13:36:53 +0200 Subject: [PATCH 09/15] docs(ids-cameras): record round-2 HW-testing findings in the exposure/gain plan Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PYQTHuSxSaLCijvayoRkX5 --- docs/plans/ids-camera-manual-exposure.md | 40 +++++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/plans/ids-camera-manual-exposure.md b/docs/plans/ids-camera-manual-exposure.md index 697e3e35..b2ef16d8 100644 --- a/docs/plans/ids-camera-manual-exposure.md +++ b/docs/plans/ids-camera-manual-exposure.md @@ -331,7 +331,7 @@ The `is not None` guards are defensive (harmless if this ever runs against an ol exposure time, and confirm the live image brightness responds and stays stable (doesn't drift back, confirming auto-exposure is actually off). -## Addendum: HW-testing findings (2026-09-14) +## Addendum: HW-testing findings, round 1 (2026-09-14) Real-hardware testing against `test_ids_camera_41.yaml` surfaced one bug and two follow-up improvements, all now implemented: @@ -353,13 +353,43 @@ follow-up improvements, all now implemented: `get_pixel_clock()`/`get_pixel_clock_range()`/`set_pixel_clock()` (`IS_PIXELCLOCK_CMD_GET`/`_GET_RANGE`/`_SET`) to `base_integration/camera.py`, exposed on `IDSCamera` as `get_exposure_time_range()`/`get_pixel_clock()`/ - `get_pixel_clock_range()`/`set_pixel_clock()` (`USER_ACCESS`, script-level for now — - no GUI pixel-clock control yet). New `exposure_time_min`/`exposure_time_max` - `Kind.config` signals are seeded in `on_connected()` and re-seeded by - `set_pixel_clock()`. + `get_pixel_clock_range()`/`set_pixel_clock()` (`USER_ACCESS`). New + `exposure_time_min`/`exposure_time_max` `Kind.config` signals are seeded in + `on_connected()` and re-seeded by `set_pixel_clock()`. - **Widget**: replaced the exposure-time spinbox with a `QSlider` bounded to `exposure_time_min`/`max` (seeded from the device_read_configuration message, same as everything else — still no polling), and reorganized the control panel: a horizontal separator below the shutter/camera-running/smear switches, then the exposure/gain section (auto-exposure toggle, auto-gain toggle, exposure slider), then another separator, then the alignment values (2D positioner + zoom) below. + +## Addendum: HW-testing findings, round 2 (2026-09-14) + +Follow-up round after the auto-gain fix confirmed working on hardware: + +- **Bug**: after a fresh device-server start the image was noticeably under-exposed, + fixed by toggling "Auto gain" off then on. Root cause: `auto_exposure_enabled`/ + `auto_gain_enabled` default to `True`, but unlike `live_mode_enabled`/`exposure_time`, + `on_connected()` never actually *applied* that default to hardware — it only left the + Signal's cached value at `True` without ever calling `set_auto_shutter()`/ + `set_auto_gain()`. `is_ResetToDefault()` (called on every connect, inside + `IDSCameraObject.__init__`) leaves the sensor's auto-shutter/auto-gain off, so the + camera actually ran with both off until an operator happened to re-toggle the GUI + switch — which is what issued the enable call for the first time. Fixed by + round-tripping both through their subscribe callbacks in `on_connected()` (` + self.auto_exposure_enabled.put(bool(self.auto_exposure_enabled.get()))` and the same + for `auto_gain_enabled`), same idiom as `exposure_time` already used. (This also fully + explains "auto gain seems to adjust once, then stay fixed" — it wasn't a one-shot + algorithm limitation, it just was never actually enabled until manually toggled; once + actually engaged it converges and correctly stays put for an unchanging scene.) +- **Bug**: the exposure-time slider allowed a literal 0 ms, which isn't physical. + Root cause: `getting_camera_status()` rounded the hardware-reported min (e.g. some + sub-0.1 ms value) to the nearest tenth of a ms for the slider's integer units, and + `round()` can round a small-but-nonzero min down to `0`. Fixed by rounding the min UP + (`math.ceil`, clamped to at least 1) and the max DOWN (`math.floor`) instead of nearest + — the slider must never claim a bound the hardware won't actually accept. +- **Feature**: added a "Pixel clock" slider to the widget (was previously script-only + via `set_pixel_clock()`), directly below the exposure-time slider in the exposure/gain + section, bounded to `pixel_clock_min`/`max` (also new `Kind.config` signals, seeded on + connect the same way as the exposure ones) — since operators hitting the exposure + slider's ceiling need this knob to raise it further, without dropping to a script. -- 2.54.0 From 8cf95196b755c12795a6d78d9bca9b9a82c08f44 Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 13:46:50 +0200 Subject: [PATCH 10/15] fix(ids-cameras): snap pixel clock to a supported value; pulse auto-gain Pixel clock: get_pixel_clock_range()'s (min, max, increment) describes a linear range, but IS_PIXELCLOCK_CMD_SET doesn't actually accept every value in it -- confirmed on hardware, 49/60/73 MHz all failed on camera 41. Added Camera.get_pixel_clock_list() (IS_PIXELCLOCK_CMD_GET_ NUMBER + _GET_LIST, the SDK's authoritative source for what's settable) and IDSCamera.get_pixel_clock_list(); IDSCamera.set_pixel_clock() now snaps to the nearest supported value before writing, instead of passing the raw requested value straight to the driver. Auto gain: HW testing found continuous auto-gain has nothing further to adjust once correctly exposed, and takes gain out of manual control. on_connected() now pulses auto_gain_enabled on then off (_AUTO_GAIN_SETTLE_S = 0.5s in between, for the already-running continuous capture to feed it a few frames), instead of leaving it enabled like auto_exposure_enabled -- still does the one-time gain correction, but leaves gain fixed under manual control afterward. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PYQTHuSxSaLCijvayoRkX5 --- .../ids_cameras/base_integration/camera.py | 47 ++++++++++-- csaxs_bec/devices/ids_cameras/ids_camera.py | 71 ++++++++++++++----- csaxs_bec/devices/sim/sim_cameras.py | 8 ++- tests/tests_devices/test_ids_camera.py | 40 +++++++++-- 4 files changed, 139 insertions(+), 27 deletions(-) diff --git a/csaxs_bec/devices/ids_cameras/base_integration/camera.py b/csaxs_bec/devices/ids_cameras/base_integration/camera.py index d818f362..5c2fa6f4 100644 --- a/csaxs_bec/devices/ids_cameras/base_integration/camera.py +++ b/csaxs_bec/devices/ids_cameras/base_integration/camera.py @@ -294,7 +294,16 @@ class Camera: return int(value.value) def get_pixel_clock_range(self) -> tuple[int, int, int]: - """Get the (min, max, increment) pixel clock range (MHz).""" + """Get the (min, max, increment) pixel clock range (MHz). + + Confirmed on hardware: this reports a *linear* range/increment, but + not every value in it is actually accepted by is_PixelClock's SET + command -- many uEye sensors only support a short discrete list of + clocks (e.g. setting 49, 60, 73 MHz all failed on camera 41, even + though they fell inside this range). Use get_pixel_clock_list() to + find out what's actually settable; treat this range as informational + only. + """ param = (ueye.UINT * 3)() check_error( self.ueye.is_PixelClock( @@ -307,12 +316,42 @@ class Camera: ) return int(param[0]), int(param[1]), int(param[2]) + def get_pixel_clock_list(self) -> list[int]: + """Get the actual list of pixel clock values (MHz) this camera + accepts -- see get_pixel_clock_range()'s docstring for why this, + not that range, is what callers should validate/snap against.""" + count = ueye.UINT() + check_error( + self.ueye.is_PixelClock( + self.cam.h_cam, + ueye.IS_PIXELCLOCK_CMD_GET_NUMBER, + count, + self.ueye.sizeof(count), + ), + "IDSCameraObject", + ) + n = int(count.value) + if n <= 0: + return [] + values = (ueye.UINT * n)() + check_error( + self.ueye.is_PixelClock( + self.cam.h_cam, + ueye.IS_PIXELCLOCK_CMD_GET_LIST, + values, + self.ueye.sizeof(values), + ), + "IDSCameraObject", + ) + return sorted(int(v) for v in values) + def set_pixel_clock(self, value: int) -> None: """Set the camera's pixel clock (MHz). Lowering it raises the max achievable exposure time (see get_exposure_range()), at the cost of - frame rate -- there is no GUI control for this yet, it's a script- - level knob for cases where the default pixel clock's exposure - ceiling is too low.""" + frame rate. Does not itself validate value against + get_pixel_clock_list() -- callers (IDSCamera.set_pixel_clock()) are + expected to snap to a supported value first; passing an unsupported + one here raises UEyeException.""" pixel_clock = ueye.UINT(value) check_error( self.ueye.is_PixelClock( diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py index e181e59c..a2b5135c 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera.py @@ -131,9 +131,15 @@ class IDSCamera(PSIDeviceBase): "get_exposure_time_range", "get_pixel_clock", "get_pixel_clock_range", + "get_pixel_clock_list", "set_pixel_clock", ] + # How long to hold auto_gain_enabled on during the connect-time pulse + # (see on_connected()) before turning it back off -- long enough for a + # few frames from the continuously-running capture to be processed. + _AUTO_GAIN_SETTLE_S = 0.5 + def __init__( self, *, @@ -415,14 +421,35 @@ class IDSCamera(PSIDeviceBase): def get_pixel_clock_range(self) -> tuple[int, int]: """Get the (min, max) pixel clock (MHz), from the cached Signal - values (seeded on connect; see on_connected()).""" + values (seeded on connect; see on_connected()). Informational only -- + see get_pixel_clock_list() for what's actually settable.""" return int(self.pixel_clock_min.get()), int(self.pixel_clock_max.get()) + def get_pixel_clock_list(self) -> list[int]: + """Get the list of pixel clock values (MHz) this camera actually + accepts. Confirmed on hardware: not every value between + get_pixel_clock_range()'s min/max is valid -- many uEye sensors only + support a short discrete list; set_pixel_clock() snaps to the + nearest one from this list rather than passing an arbitrary value + straight to the driver.""" + return self.cam.get_pixel_clock_list() + def set_pixel_clock(self, value: int) -> None: """Set the pixel clock (MHz). Lowering it raises the max achievable exposure time (see get_exposure_time_range()), at the cost of frame - rate.""" - self.pixel_clock.put(int(value)) + rate. Snaps to the nearest value in get_pixel_clock_list() -- passing + an arbitrary MHz value from that (min, max) range is not guaranteed + to be accepted by the driver.""" + target = int(value) + options = self.cam.get_pixel_clock_list() + if options and target not in options: + nearest = min(options, key=lambda v: abs(v - target)) + logger.info( + f"{self.name}: pixel_clock={target} is not one of this camera's supported " + f"values {options}; using nearest supported value {nearest} instead." + ) + target = nearest + self.pixel_clock.put(target) ############## User Interface Methods ############## @@ -431,21 +458,33 @@ class IDSCamera(PSIDeviceBase): self.cam.force_monochrome = self._force_monochrome self.cam.on_connect() self.live_mode_enabled.put(bool(self._inputs.get("live_mode", False))) - # auto_exposure_enabled/auto_gain_enabled default to True, but there is - # no SDK query for the camera's *current* auto-shutter/auto-gain state - # (only enable-setters) -- so unlike live_mode_enabled/exposure_time, - # this used to just leave the Signal at its declared default without - # ever telling the hardware. is_ResetToDefault() (called during + # auto_exposure_enabled defaults to True, but there is no SDK query + # for the camera's *current* auto-shutter state (only an + # enable-setter) -- so unlike live_mode_enabled/exposure_time, this + # used to just leave the Signal at its declared default without ever + # telling the hardware. is_ResetToDefault() (called during # IDSCameraObject init, i.e. every connect) leaves the sensor with - # auto-shutter/auto-gain off, so a fresh session started under-exposed - # until an operator happened to toggle the GUI switch off and back on - # -- which is what actually issued the enable call for the first - # time. Round-trip both through their subscribe callbacks now (same - # idiom as exposure_time below) so the declared default is actually - # enforced on hardware on every connect, not just once some operator - # notices and re-toggles it. + # auto-shutter off, so a fresh session started under-exposed until an + # operator happened to toggle the GUI switch off and back on -- which + # is what actually issued the enable call for the first time. + # Round-trip through its subscribe callback now (same idiom as + # exposure_time below) so the declared default is actually enforced + # on hardware on every connect. self.auto_exposure_enabled.put(bool(self.auto_exposure_enabled.get())) - self.auto_gain_enabled.put(bool(self.auto_gain_enabled.get())) + # auto_gain_enabled: HW testing found continuous auto-gain isn't + # useful here -- once correctly exposed it has nothing further to + # adjust, and leaving it on takes gain out of the operator's manual + # control. But *some* one-time gain correction at connect is still + # needed (same under-exposed-at-start symptom as above, confirmed to + # be specifically about gain, not just auto-exposure). So pulse it on + # briefly, then back off -- "in the background": one settle period + # to let the driver correct the gain from a few live frames, then + # leave gain fixed at whatever it converged to, under manual control + # from then on. This is the only place auto_gain_enabled is toggled; + # there is no persistent GUI control for it (see OMNY_XRayEye). + self.auto_gain_enabled.put(True) + time.sleep(self._AUTO_GAIN_SETTLE_S) + self.auto_gain_enabled.put(False) # Seed exposure_time from the real hardware value once, so the GUI shows # a real number immediately on connect instead of the 0.0 placeholder. # This round-trips through _on_exposure_time_changed, which writes the diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index 8ca3ecf2..dba8b06a 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -242,7 +242,10 @@ class _SimIDSBackend: self._auto_exposure = True self._auto_gain = True self._pixel_clock = 20 # MHz - self._pixel_clock_range = (5, 40, 1) # (min, max, increment) MHz + self._pixel_clock_range = (5, 40, 1) # (min, max, increment) MHz -- informational only + # Mimics real uEye hardware only accepting a short discrete list, not + # every value in the range above (see get_pixel_clock_list()). + self._pixel_clock_options = [5, 10, 20, 30, 40] self._width = width self._height = height self._rotation_coupling = rotation_coupling @@ -313,6 +316,9 @@ class _SimIDSBackend: def get_pixel_clock_range(self) -> tuple[int, int, int]: return self._pixel_clock_range + def get_pixel_clock_list(self) -> list[int]: + return list(self._pixel_clock_options) + def set_pixel_clock(self, value: int): self._pixel_clock = int(value) diff --git a/tests/tests_devices/test_ids_camera.py b/tests/tests_devices/test_ids_camera.py index 2ad8e104..704763df 100644 --- a/tests/tests_devices/test_ids_camera.py +++ b/tests/tests_devices/test_ids_camera.py @@ -30,6 +30,11 @@ def ids_camera(): camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 1000.0, 0.1)) camera.cam.get_pixel_clock = mock.Mock(return_value=20) camera.cam.get_pixel_clock_range = mock.Mock(return_value=(5, 40, 1)) + camera.cam.get_pixel_clock_list = mock.Mock(return_value=[5, 10, 20, 40]) + # on_connected() pulses auto_gain_enabled on then off with a real sleep + # in between (see IDSCamera._AUTO_GAIN_SETTLE_S) -- skip the wait in + # tests, only hardware needs the settle time. + camera._AUTO_GAIN_SETTLE_S = 0 yield camera camera.stop_live_mode() @@ -198,17 +203,27 @@ def test_auto_exposure_hardware_failure_is_logged_not_raised(ids_camera): ids_camera.cam.set_auto_shutter.assert_called_once_with(False) -def test_on_connected_applies_auto_exposure_and_gain_defaults_to_hardware(ids_camera): - """auto_exposure_enabled/auto_gain_enabled default to True but, unlike - live_mode_enabled/exposure_time, were never actually applied to hardware +def test_on_connected_applies_auto_exposure_default_to_hardware(ids_camera): + """auto_exposure_enabled defaults to True but, unlike + live_mode_enabled/exposure_time, was never actually applied to hardware at connect -- meaning a fresh session left the camera in whatever is_ResetToDefault() leaves it in (observed: auto off) regardless of the - Signal's declared default. on_connected() must now round-trip both - through their subscribe callbacks so the real SDK call is issued.""" + Signal's declared default. on_connected() must now round-trip it through + its subscribe callback so the real SDK call is issued.""" ids_camera.cam.on_connect = mock.Mock() ids_camera.on_connected() ids_camera.cam.set_auto_shutter.assert_called_once_with(True) - ids_camera.cam.set_auto_gain.assert_called_once_with(True) + + +def test_on_connected_pulses_auto_gain_on_then_off(ids_camera): + """auto_gain_enabled gets pulsed on then off at connect (a one-time gain + correction), unlike auto_exposure_enabled which stays on -- HW testing + found continuous auto-gain has nothing useful left to do once correctly + exposed, and it should end up under the operator's manual control.""" + ids_camera.cam.on_connect = mock.Mock() + ids_camera.on_connected() + ids_camera.cam.set_auto_gain.assert_has_calls([mock.call(True), mock.call(False)]) + assert ids_camera.auto_gain_enabled.get() is False def test_on_connected_seeds_pixel_clock(ids_camera): @@ -228,6 +243,19 @@ def test_set_pixel_clock_reseeds_exposure_range(ids_camera): assert ids_camera.get_exposure_time_range() == (0.2, 200.0) +def test_set_pixel_clock_snaps_to_nearest_supported_value(ids_camera): + """Confirmed on hardware: not every value between get_pixel_clock_range()'s + min/max is actually accepted (49, 60, 73 MHz were all rejected on camera + 41) -- set_pixel_clock() must snap to the nearest value in + get_pixel_clock_list() instead of passing the raw slider value through.""" + ids_camera.cam.get_pixel_clock_list = mock.Mock(return_value=[5, 10, 20, 40]) + ids_camera.cam.set_pixel_clock = mock.Mock() + ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 100.0, 0.1)) + ids_camera.set_pixel_clock(37) # nearest supported value is 40 + ids_camera.cam.set_pixel_clock.assert_called_once_with(40) + assert ids_camera.get_pixel_clock() == 40 + + def test_push_smear_preview_no_rotation_compensation(ids_camera): """smear_preview has no rotation_90/transpose configured, so pushed data passes straight through unmodified -- unlike push_preview_image, no -- 2.54.0 From 9e1a3f68bab527a9286120d831dac8de3122b249 Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 13:47:00 +0200 Subject: [PATCH 11/15] feat(xrayeye): remove the Auto gain toggle auto_gain_enabled is now purely an internal connect-time mechanism (IDSCamera pulses it on then off at connect, see its on_connected()) rather than a persistent operator control -- continuous auto-gain had nothing further to adjust once correctly exposed, and having it enabled took gain out of manual control. Still reachable via IDSCamera.set_auto_gain_enabled() (USER_ACCESS) for scripted use. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PYQTHuSxSaLCijvayoRkX5 --- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index bed4e387..0f349a31 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -428,20 +428,18 @@ class OMNY_XRayEye(BECWidget, QWidget): exposure_grid.setVerticalSpacing(4) exposure_grid.setColumnStretch(0, 1) + # No "Auto gain" control here -- HW testing found continuous + # auto-gain isn't useful once correctly exposed, and takes gain out + # of manual control. IDSCamera now pulses it on then off internally, + # once, at connect (see its on_connected()); there is no persistent + # UI for it. self.auto_exposure_label = QLabel("Auto exposure", parent=self) self.auto_exposure_toggle = ToggleSwitch(parent=self) self.auto_exposure_toggle.checked = True self.auto_exposure_toggle.enabled.connect(self.auto_exposure_enabled_changed) - self.auto_gain_label = QLabel("Auto gain", parent=self) - self.auto_gain_toggle = ToggleSwitch(parent=self) - self.auto_gain_toggle.checked = True - self.auto_gain_toggle.enabled.connect(self.auto_gain_enabled_changed) - exposure_grid.addWidget(self.auto_exposure_label, 0, 1, _right_vcenter) exposure_grid.addWidget(self.auto_exposure_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter) - exposure_grid.addWidget(self.auto_gain_label, 0, 3, _right_vcenter) - exposure_grid.addWidget(self.auto_gain_toggle, 0, 4, Qt.AlignmentFlag.AlignVCenter) # QSlider only takes ints, so exposure time is tracked in tenths of a # ms internally (_EXPOSURE_SLIDER_SCALE) for one decimal of @@ -735,7 +733,6 @@ class OMNY_XRayEye(BECWidget, QWidget): self.shutter_toggle, self.camera_running_toggle, self.auto_exposure_toggle, - self.auto_gain_toggle, ) def _set_queue_toggles_blocked(self, blocked: bool): @@ -987,11 +984,8 @@ class OMNY_XRayEye(BECWidget, QWidget): self.exposure_time_slider.setEnabled(not enabled) self.auto_exposure_toggle.blockSignals(False) - auto_gain = signals.get(f"{CAMERA[0]}_auto_gain_enabled") - if auto_gain is not None: - self.auto_gain_toggle.blockSignals(True) - self.auto_gain_toggle.checked = bool(auto_gain.get("value")) - self.auto_gain_toggle.blockSignals(False) + # No auto_gain_enabled handling here -- see the comment above the + # (removed) toggle in _init_ui. # Reseed the pixel-clock slider's bounds/value before the exposure # ones below -- changing the pixel clock changes the exposure range, @@ -1046,16 +1040,6 @@ class OMNY_XRayEye(BECWidget, QWidget): self.exposure_time_slider.setEnabled(not enabled) self.auto_exposure_toggle.blockSignals(False) - @SafeSlot(bool) - def auto_gain_enabled_changed(self, enabled: bool): - if self._manual_toggle_blocked_by_queue(): - logger.warning("Ignoring auto-gain toggle while scan queue is busy.") - return - self.auto_gain_toggle.blockSignals(True) - self.dev.get(CAMERA[0]).auto_gain_enabled.put(enabled) - self.auto_gain_toggle.checked = enabled - self.auto_gain_toggle.blockSignals(False) - def _update_exposure_time_value_label(self, raw_value: int): self.exposure_time_value_label.setText( f"{raw_value / self._EXPOSURE_SLIDER_SCALE:.1f} ms" -- 2.54.0 From a550fd4f03be15be415070a99b4f128f163be8dc Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 13:47:07 +0200 Subject: [PATCH 12/15] docs(ids-cameras): record round-3 HW-testing findings in the exposure/gain plan Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PYQTHuSxSaLCijvayoRkX5 --- docs/plans/ids-camera-manual-exposure.md | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/plans/ids-camera-manual-exposure.md b/docs/plans/ids-camera-manual-exposure.md index b2ef16d8..bc14075c 100644 --- a/docs/plans/ids-camera-manual-exposure.md +++ b/docs/plans/ids-camera-manual-exposure.md @@ -393,3 +393,33 @@ Follow-up round after the auto-gain fix confirmed working on hardware: section, bounded to `pixel_clock_min`/`max` (also new `Kind.config` signals, seeded on connect the same way as the exposure ones) — since operators hitting the exposure slider's ceiling need this knob to raise it further, without dropping to a script. + +## Addendum: HW-testing findings, round 3 (2026-09-14) + +Follow-up after confirming the round-1/2 fixes on hardware (startup exposure now good). +Two more findings: + +- **"Auto gain is kind of useless" (design change, not a bug)**: continuous auto-gain + has nothing further to adjust once the image is correctly exposed, and leaving it + enabled takes gain out of the operator's manual control. Changed `on_connected()` to + pulse `auto_gain_enabled` on then off (`_AUTO_GAIN_SETTLE_S = 0.5`s in between, long + enough for a few frames from the already-running continuous capture to be processed), + instead of leaving it enabled like `auto_exposure_enabled`. This still fixes the + under-exposed-at-start symptom (a one-time gain correction) while leaving gain fixed + under manual control afterward. Removed the "Auto gain" toggle from `OMNY_XRayEye` + entirely (`_init_ui`, `getting_camera_status`, `_queue_guarded_toggles`, + `auto_gain_enabled_changed`) — `auto_gain_enabled` is now purely an internal + connect-time mechanism (still on `IDSCamera.USER_ACCESS` via `set_auto_gain_enabled()` + for scripted use), not a persistent GUI control. +- **Bug**: the pixel-clock slider raised `failed to set pixel_clock= on hardware` + for essentially every value dragged to (49, 60, 73 MHz all failed on camera 41). Root + cause: `get_pixel_clock_range()`'s (min, max, increment) describes a *linear* range, + but `IS_PIXELCLOCK_CMD_SET` doesn't actually accept every value in it — many uEye + sensors only support a short discrete list of pixel clocks. Added + `Camera.get_pixel_clock_list()` (`IS_PIXELCLOCK_CMD_GET_NUMBER` + + `IS_PIXELCLOCK_CMD_GET_LIST`, the SDK's authoritative source for what's actually + settable) and `IDSCamera.get_pixel_clock_list()`. `IDSCamera.set_pixel_clock()` now + snaps to the nearest value in that list before writing, instead of passing the raw + slider value straight to the driver — so the widget doesn't need to know about the + discrete list itself; it just gets a corrected value back on the next + `device_read_configuration` message, same round-trip pattern as everything else here. -- 2.54.0 From afca16bfe39a26c0fba884fa0281df745f7de906 Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 13:56:37 +0200 Subject: [PATCH 13/15] fix(xrayeye): make the pixel clock slider index-based over supported values The server-side snap-to-nearest fix (previous commit) corrected what got written to hardware, but the slider itself still let an operator drag to -- and briefly display -- any integer in [min, max] before self-correcting on the next status message, which reads as "the slider allows any setting" even though bad ones don't stick. pixel_clock_slider's range is now [0, len(options)-1], an index into the real discrete list of supported pixel clocks (fetched once over RPC at widget startup via get_pixel_clock_list(), disabled until it arrives -- not a polling loop, this list never changes at runtime). Every position the slider can physically be dragged to is therefore one the hardware has already confirmed it accepts. getting_camera_status() now maps the hardware-reported MHz value to the nearest option's index instead of setting the slider to a raw MHz value. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PYQTHuSxSaLCijvayoRkX5 --- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 105 ++++++++++++++---- 1 file changed, 86 insertions(+), 19 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 0f349a31..fe5f6a74 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -280,6 +280,12 @@ class OMNY_XRayEye(BECWidget, QWidget): super().__init__(parent=parent, **kwargs) self._live_view_signal = CAMERA[1] self._last_smear_composite = None + # pixel_clock_slider is index-based over this list (populated once, + # see _init_pixel_clock_options()) rather than a raw MHz range -- + # many uEye sensors only accept a short discrete list of pixel + # clocks, not every value in [min, max] (confirmed on hardware). + self._pixel_clock_options: list[int] = [] + self._last_pixel_clock_mhz: int | None = None self._connected_motor = None self._dap_params_forwarding_connected = False self._queue_busy = False @@ -322,6 +328,7 @@ class OMNY_XRayEye(BECWidget, QWidget): self.resize(800, 600) QTimer.singleShot(0, self._init_queue_status) QTimer.singleShot(0, self._init_gui_trigger) + QTimer.singleShot(0, self._init_pixel_clock_options) def _init_ui(self): self.root_layout = QVBoxLayout(self) @@ -467,9 +474,18 @@ class OMNY_XRayEye(BECWidget, QWidget): # Pixel clock (MHz): lowering it raises the max achievable exposure # time (at the cost of frame rate) -- exposed here since operators # hitting the exposure slider's ceiling need this knob to go further. + # Many uEye sensors only accept a short discrete list of pixel + # clocks (confirmed on hardware -- most values in the driver's own + # reported min/max range were rejected), so this slider's *position* + # is an index into self._pixel_clock_options, not a raw MHz value -- + # every position it can land on is therefore one the hardware + # actually accepts. The real list is fetched once, over RPC, in + # _init_pixel_clock_options() (it's static per camera, not something + # that needs a live subscription); disabled until then. self.pixel_clock_label = QLabel("Pixel clock", parent=self) self.pixel_clock_slider = QSlider(Qt.Orientation.Horizontal, parent=self) - self.pixel_clock_slider.setRange(1, 100) # MHz; placeholder, reseeded on connect + self.pixel_clock_slider.setRange(0, 0) # placeholder; see _set_pixel_clock_options() + self.pixel_clock_slider.setEnabled(False) self.pixel_clock_value_label = QLabel("-- MHz", parent=self) self.pixel_clock_value_label.setMinimumWidth(60) self.pixel_clock_slider.valueChanged.connect(self._update_pixel_clock_value_label) @@ -987,23 +1003,16 @@ class OMNY_XRayEye(BECWidget, QWidget): # No auto_gain_enabled handling here -- see the comment above the # (removed) toggle in _init_ui. - # Reseed the pixel-clock slider's bounds/value before the exposure - # ones below -- changing the pixel clock changes the exposure range, - # and both arrive together in this same message. - pixel_clock_min = signals.get(f"{CAMERA[0]}_pixel_clock_min") - pixel_clock_max = signals.get(f"{CAMERA[0]}_pixel_clock_max") - if pixel_clock_min is not None and pixel_clock_max is not None: - lo = int(pixel_clock_min.get("value")) - hi = int(pixel_clock_max.get("value")) - if (lo, hi) != (self.pixel_clock_slider.minimum(), self.pixel_clock_slider.maximum()): - self.pixel_clock_slider.setRange(lo, hi) - + # Update the pixel-clock slider's position before the exposure ones + # below -- changing the pixel clock changes the exposure range, and + # both arrive together in this same message. (No range/bounds to + # reseed here -- pixel_clock_slider's range is fixed once + # self._pixel_clock_options is populated; see _init_pixel_clock_ + # options()/_set_pixel_clock_options(). pixel_clock_min/max still + # exist on the device as informational metadata, just unused here.) pixel_clock = signals.get(f"{CAMERA[0]}_pixel_clock") if pixel_clock is not None: - self.pixel_clock_slider.blockSignals(True) - self.pixel_clock_slider.setValue(int(pixel_clock.get("value"))) - self.pixel_clock_slider.blockSignals(False) - self._update_pixel_clock_value_label(self.pixel_clock_slider.value()) + self._set_pixel_clock_display(int(pixel_clock.get("value"))) # Reseed the exposure slider's bounds next (rare -- only changes if # the pixel clock changed), so a value arriving in the same message @@ -1049,11 +1058,69 @@ class OMNY_XRayEye(BECWidget, QWidget): value_ms = self.exposure_time_slider.value() / self._EXPOSURE_SLIDER_SCALE self.dev.get(CAMERA[0]).exposure_time.put(value_ms) - def _update_pixel_clock_value_label(self, value: int): - self.pixel_clock_value_label.setText(f"{value} MHz") + def _init_pixel_clock_options(self): + """One-time fetch of the camera's actually-supported pixel clocks + (see get_pixel_clock_list()'s docstring for why this can't just be + [min, max] from a Kind.config signal) -- not a polling loop, this + list is static per camera, so a single RPC call at widget startup is + the right cost/benefit trade-off versus adding a whole signal path + for something that never changes at runtime.""" + try: + options = self.dev.get(CAMERA[0]).get_pixel_clock_list() + except Exception as exc: + logger.warning(f"Failed to fetch pixel clock options for OMNY_XRayEye: {exc}") + return + self._set_pixel_clock_options(sorted(int(v) for v in options)) + + def _set_pixel_clock_options(self, options: list[int]): + self._pixel_clock_options = options + if not options: + self.pixel_clock_slider.setEnabled(False) + return + self.pixel_clock_slider.blockSignals(True) + self.pixel_clock_slider.setRange(0, len(options) - 1) + self.pixel_clock_slider.blockSignals(False) + self.pixel_clock_slider.setEnabled(True) + if self._last_pixel_clock_mhz is not None: + # A status message already arrived before this RPC call + # returned -- apply it now that we can actually resolve it to a + # slider position. + self._set_pixel_clock_display(self._last_pixel_clock_mhz) + else: + # No status message yet either -- keep the label in sync with + # the slider's default position (index 0, the lowest option). + self._update_pixel_clock_value_label(self.pixel_clock_slider.value()) + + def _set_pixel_clock_display(self, mhz: int): + """Move the slider to the option nearest `mhz` (from hardware, e.g. + via getting_camera_status()) and update the value label -- never a + raw MHz value on the slider itself, only a valid index.""" + self._last_pixel_clock_mhz = mhz + if not self._pixel_clock_options: + # Options not fetched yet -- _set_pixel_clock_options() will + # call back into this once they arrive. + self.pixel_clock_value_label.setText(f"{mhz} MHz") + return + index = min( + range(len(self._pixel_clock_options)), + key=lambda i: abs(self._pixel_clock_options[i] - mhz), + ) + self.pixel_clock_slider.blockSignals(True) + self.pixel_clock_slider.setValue(index) + self.pixel_clock_slider.blockSignals(False) + self._update_pixel_clock_value_label(index) + + def _update_pixel_clock_value_label(self, index: int): + if 0 <= index < len(self._pixel_clock_options): + self.pixel_clock_value_label.setText(f"{self._pixel_clock_options[index]} MHz") + else: + self.pixel_clock_value_label.setText("-- MHz") def pixel_clock_submitted(self): - self.dev.get(CAMERA[0]).pixel_clock.put(self.pixel_clock_slider.value()) + index = self.pixel_clock_slider.value() + if not 0 <= index < len(self._pixel_clock_options): + return + self.dev.get(CAMERA[0]).pixel_clock.put(self._pixel_clock_options[index]) @SafeSlot(bool) def opening_shutter(self, enabled: bool): -- 2.54.0 From 2f6f4456f9e5676b57351c69fc7ac090ace8468f Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 14 Sep 2026 13:56:42 +0200 Subject: [PATCH 14/15] docs(ids-cameras): record round-4 HW-testing findings in the exposure/gain plan Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PYQTHuSxSaLCijvayoRkX5 --- docs/plans/ids-camera-manual-exposure.md | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/plans/ids-camera-manual-exposure.md b/docs/plans/ids-camera-manual-exposure.md index bc14075c..ce299e54 100644 --- a/docs/plans/ids-camera-manual-exposure.md +++ b/docs/plans/ids-camera-manual-exposure.md @@ -423,3 +423,31 @@ Two more findings: slider value straight to the driver — so the widget doesn't need to know about the discrete list itself; it just gets a corrected value back on the next `device_read_configuration` message, same round-trip pattern as everything else here. + +## Addendum: HW-testing findings, round 4 (2026-09-14) + +Round 3's server-side snap-to-nearest fixed what actually got *written*, but the +slider itself still let an operator drag to (and briefly display) any integer in +[min, max] before self-correcting on the next status message — reported as "the +pixel clock slider still allows for any integer setting and not the specific ones". +That's a worse interaction than just rejecting bad values: the operator sees the +slider land somewhere it can't actually stay. + +Fixed by making `pixel_clock_slider` index-based over the real discrete list instead +of ranged over `[pixel_clock_min, pixel_clock_max]`: + +- `OMNY_XRayEye` fetches `get_pixel_clock_list()` once over RPC at widget startup + (`_init_pixel_clock_options()`, `QTimer.singleShot(0, ...)` alongside the widget's + other one-time init calls) — this list is static per camera, so a one-time call is + the right trade-off versus adding a whole new signal/subscription path for something + that never changes at runtime. The slider is disabled until this arrives. +- The slider's range becomes `[0, len(options)-1]`; its *position* is an index into + `self._pixel_clock_options`, so every position it can physically be dragged to + (`pixel_clock_submitted()`) is one the hardware has already confirmed it accepts — + no more relying on a post-hoc correction the operator has to notice. +- `getting_camera_status()`'s pixel_clock handling now maps the hardware-reported MHz + value to the *nearest* option's index (`_set_pixel_clock_display()`) rather than + setting the slider to a raw MHz value directly. +- `pixel_clock_min`/`pixel_clock_max` signals are unchanged on `IDSCamera` (still + informational, still seeded on connect) but are no longer read by the widget, which + no longer needs a numeric range at all. -- 2.54.0 From f1e9b2b3bb8d2284666bbfc705915bd16f868de7 Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 14 Sep 2026 16:57:53 +0200 Subject: [PATCH 15/15] feat(ids-cameras): add IDSCameraSettings widget for multi-camera exposure/gain control OMNY_XRayEye's exposure/pixel-clock controls are hardcoded to a single camera (cam_xeye). Several beamline configs define more than one IDSCamera at once (e.g. ptycho_omny.yaml's cam200..cam203), none of which were reachable from that widget. Adds a standalone IDSCameraSettings panel: a camera-selection dropdown (filtered by deviceClass, extensible to other camera classes later) plus auto-exposure/exposure-time/pixel-clock controls that re-subscribe to the selected camera's device_read_configuration on every switch, seeded immediately from the retained redis value. Scaffolded via bw-generate-cli --target csaxs_bec (client.py, designer_plugins.py). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TUimPoyFQRvxM6R3njvuVj --- csaxs_bec/bec_widgets/widgets/client.py | 19 + .../bec_widgets/widgets/designer_plugins.py | 5 + .../widgets/ids_camera_settings/__init__.py | 3 + .../ids_camera_settings.py | 511 ++++++++++++++++++ .../ids_camera_settings.pyproject | 1 + .../ids_camera_settings_plugin.py | 57 ++ .../register_ids_camera_settings.py | 15 + 7 files changed, 611 insertions(+) create mode 100644 csaxs_bec/bec_widgets/widgets/ids_camera_settings/__init__.py create mode 100644 csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.py create mode 100644 csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.pyproject create mode 100644 csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings_plugin.py create mode 100644 csaxs_bec/bec_widgets/widgets/ids_camera_settings/register_ids_camera_settings.py diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index abe03e3b..14541d6c 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -14,6 +14,7 @@ logger = bec_logger.logger _Widgets = { "DataViewer": "DataViewer", + "IDSCameraSettings": "IDSCameraSettings", "OMNY_SampleStorage": "OMNY_SampleStorage", "OMNY_TomoParams": "OMNY_TomoParams", "OMNY_XRayEye": "OMNY_XRayEye", @@ -47,6 +48,24 @@ class DataViewer(RPCBase): """ +class IDSCameraSettings(RPCBase): + """Pick a configured IDS camera and adjust its exposure time / pixel clock.""" + + _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings" + + @rpc_call + def selected_camera(): + """ + Intermediate wrapper used so that the user can optionally chain .setter(...). + """ + + @rpc_call + def set_camera(self, name: "str"): + """ + Select a camera by device name, as if chosen from the dropdown. + """ + + class OMNY_SampleStorage(RPCBase): """View and correct the FlOMNI sample-storage records.""" diff --git a/csaxs_bec/bec_widgets/widgets/designer_plugins.py b/csaxs_bec/bec_widgets/widgets/designer_plugins.py index ad5b6c7c..6f9f30d9 100644 --- a/csaxs_bec/bec_widgets/widgets/designer_plugins.py +++ b/csaxs_bec/bec_widgets/widgets/designer_plugins.py @@ -6,6 +6,10 @@ from __future__ import annotations designer_plugins = { "DataViewer": ("csaxs_bec.bec_widgets.widgets.data_viewer.data_viewer", "DataViewer"), + "IDSCameraSettings": ( + "csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings", + "IDSCameraSettings", + ), "OMNY_SampleStorage": ( "csaxs_bec.bec_widgets.widgets.sample_storage.sample_storage", "OMNY_SampleStorage", @@ -22,6 +26,7 @@ designer_plugins = { widget_icons = { "DataViewer": "find_in_page", + "IDSCameraSettings": "photo_camera", "OMNY_SampleStorage": "widgets", "OMNY_TomoParams": "widgets", "OMNY_XRayEye": "widgets", diff --git a/csaxs_bec/bec_widgets/widgets/ids_camera_settings/__init__.py b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/__init__.py new file mode 100644 index 00000000..a6f28fee --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/__init__.py @@ -0,0 +1,3 @@ +from csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings import IDSCameraSettings + +__all__ = ["IDSCameraSettings"] diff --git a/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.py b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.py new file mode 100644 index 00000000..4fca2fc0 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.py @@ -0,0 +1,511 @@ +"""IDS camera settings widget for cSAXS -- pick a configured IDS camera from +a dropdown and adjust its exposure time / pixel clock. + +The exposure/pixel-clock control logic mirrors OMNY_XRayEye +(bec_widgets/widgets/xray_eye/x_ray_eye.py), which implements the same +controls but hardcoded to a single camera (module-level CAMERA constant). +This widget generalizes that to whichever camera is selected, for beamlines +that configure more than one IDS camera at once (e.g. ptycho_omny.yaml's +cam200..cam203). +""" + +from __future__ import annotations + +import math + +from bec_lib import bec_logger +from bec_lib.endpoints import MessageEndpoints +from bec_widgets import BECWidget, SafeProperty, SafeSlot +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( + DeviceComboBox, +) +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch +from qtpy.QtCore import Qt, QTimer +from qtpy.QtWidgets import QFrame, QGridLayout, QLabel, QSizePolicy, QSlider, QVBoxLayout, QWidget + +logger = bec_logger.logger + + +class IDSCameraComboBox(DeviceComboBox): + """Dropdown listing configured IDS cameras. + + DeviceComboBox's device_filter only distinguishes bec_lib.device base + classes (Device/Positioner/Signal/ComputedSignal) and can't express + "exactly IDSCamera", so this overrides update_devices_from_filters() to + filter by each device's deviceClass config instead -- the same technique + device_table.py uses to filter/display device classes client-side. + + To support another camera class later (once it grows the same + Kind.config exposure/pixel-clock signals this widget depends on), add + its bare class name to CAMERA_DEVICE_CLASSES -- no other change should + be required here. + """ + + PLUGIN = False + RPC = False + + CAMERA_DEVICE_CLASSES: tuple[str, ...] = ("IDSCamera",) + + @staticmethod + def _device_class_name(device) -> str: + """Bare class name from a device's deviceClass config, e.g. + "csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera" -> "IDSCamera".""" + device_class = (getattr(device, "_config", None) or {}).get("deviceClass") or "" + return device_class.rsplit(".", 1)[-1] if device_class else "" + + @SafeSlot() + def update_devices_from_filters(self): + if not self.apply_filter: + return + self.devices = sorted( + device.name + for device in self.dev.enabled_devices + if self._device_class_name(device) in self.CAMERA_DEVICE_CLASSES + ) + + +class IDSCameraSettings(BECWidget, QWidget): + """Pick a configured IDS camera and adjust its exposure time / pixel clock.""" + + ICON_NAME = "photo_camera" + PLUGIN = True + USER_ACCESS = ["selected_camera", "set_camera"] + + # exposure_time_slider works in tenths of a ms internally (QSlider is + # int-only); divide by this to get ms. Same idiom as OMNY_XRayEye. + _EXPOSURE_SLIDER_SCALE = 10 + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self.get_bec_shortcuts() + + self._camera_name: str | None = None + self._auto_exposure_enabled = True + # pixel_clock_slider is index-based over this list (re-fetched once + # per camera, see _init_pixel_clock_options()) rather than a raw MHz + # range -- many uEye sensors only accept a short discrete list of + # pixel clocks, not every value in [min, max] (see OMNY_XRayEye). + self._pixel_clock_options: list[int] = [] + self._last_pixel_clock_mhz: int | None = None + + self._queue_busy = False + self._queue_idle_timer = QTimer(self) + self._queue_idle_timer.setSingleShot(True) + self._queue_idle_timer.setInterval(800) + self._queue_idle_timer.timeout.connect(self._release_queue_busy) + + self._init_ui() + self._make_connections() + self._reset_controls_to_placeholder() + + self.bec_dispatcher.connect_slot( + self.on_queue_status_update, MessageEndpoints.scan_queue_status() + ) + QTimer.singleShot(0, self._init_queue_status) + + def _init_ui(self): + layout = QVBoxLayout(self) + + camera_row = QGridLayout() + camera_row.setColumnStretch(1, 1) + self.camera_label = QLabel("Camera", parent=self) + self.camera_combo = IDSCameraComboBox(parent=self) + self.camera_combo.set_first_element_as_empty = True + camera_row.addWidget(self.camera_label, 0, 0) + camera_row.addWidget(self.camera_combo, 0, 1) + layout.addLayout(camera_row) + + self.status_label = QLabel(parent=self) + layout.addWidget(self.status_label) + + layout.addWidget(self._create_separator()) + + _right_vcenter = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + + self.exposure_grid_widget = QWidget(parent=self) + exposure_grid = QGridLayout(self.exposure_grid_widget) + exposure_grid.setHorizontalSpacing(8) + exposure_grid.setVerticalSpacing(4) + exposure_grid.setColumnStretch(0, 1) + + # No "Auto gain" control here -- see OMNY_XRayEye, which dropped it + # after HW testing found continuous auto-gain isn't useful once + # correctly exposed. IDSCamera pulses it on then off internally, + # once, at connect; there is no persistent UI for it. + self.auto_exposure_label = QLabel("Auto exposure", parent=self) + self.auto_exposure_toggle = ToggleSwitch(parent=self) + self.auto_exposure_toggle.checked = True + self.auto_exposure_toggle.enabled.connect(self.auto_exposure_enabled_changed) + exposure_grid.addWidget(self.auto_exposure_label, 0, 1, _right_vcenter) + exposure_grid.addWidget(self.auto_exposure_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter) + + # QSlider only takes ints, so exposure time is tracked in tenths of a + # ms internally (_EXPOSURE_SLIDER_SCALE) for one decimal of + # resolution on the display label. + self.exposure_time_label = QLabel("Exposure time", parent=self) + self.exposure_time_slider = QSlider(Qt.Orientation.Horizontal, parent=self) + self.exposure_time_slider.setRange(1, 10000) # placeholder; reseeded per camera + self.exposure_time_value_label = QLabel("-- ms", parent=self) + self.exposure_time_value_label.setMinimumWidth(60) + self.exposure_time_slider.valueChanged.connect(self._update_exposure_time_value_label) + # sliderReleased (fires once, on mouse-up), not valueChanged (fires on + # every tick while dragging) -- don't hammer the device. + self.exposure_time_slider.sliderReleased.connect(self.exposure_time_submitted) + exposure_grid.addWidget(self.exposure_time_label, 1, 1, _right_vcenter) + exposure_grid.addWidget(self.exposure_time_slider, 1, 2, 1, 2) + exposure_grid.addWidget(self.exposure_time_value_label, 1, 4, Qt.AlignmentFlag.AlignVCenter) + + # Pixel clock (MHz): lowering it raises the max achievable exposure + # time, at the cost of frame rate. Many uEye sensors only accept a + # short discrete list of pixel clocks, so this slider's *position* is + # an index into self._pixel_clock_options, not a raw MHz value. + self.pixel_clock_label = QLabel("Pixel clock", parent=self) + self.pixel_clock_slider = QSlider(Qt.Orientation.Horizontal, parent=self) + self.pixel_clock_slider.setRange(0, 0) # placeholder; see _set_pixel_clock_options() + self.pixel_clock_value_label = QLabel("-- MHz", parent=self) + self.pixel_clock_value_label.setMinimumWidth(60) + self.pixel_clock_slider.valueChanged.connect(self._update_pixel_clock_value_label) + self.pixel_clock_slider.sliderReleased.connect(self.pixel_clock_submitted) + exposure_grid.addWidget(self.pixel_clock_label, 2, 1, _right_vcenter) + exposure_grid.addWidget(self.pixel_clock_slider, 2, 2, 1, 2) + exposure_grid.addWidget(self.pixel_clock_value_label, 2, 4, Qt.AlignmentFlag.AlignVCenter) + + layout.addWidget(self.exposure_grid_widget) + layout.addStretch() + + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + + def _make_connections(self): + self.camera_combo.device_selected.connect(self._on_camera_selected) + self.camera_combo.device_reset.connect(self._on_camera_reset) + + def _create_separator(self): + sep = QFrame(parent=self) + sep.setFrameShape(QFrame.Shape.HLine) + sep.setFrameShadow(QFrame.Shadow.Sunken) + sep.setLineWidth(1) + return sep + + ################################################################################ + # Camera selection + ################################################################################ + + @SafeSlot(str) + def _on_camera_selected(self, camera_name: str): + if not camera_name or camera_name == self._camera_name: + return + self._switch_camera(camera_name) + + @SafeSlot() + def _on_camera_reset(self): + if self._camera_name is not None: + self._switch_camera(None) + + @SafeProperty(str) + def selected_camera(self) -> str: + """Name of the currently selected IDS camera, or an empty string if none.""" + return self._camera_name or "" + + @SafeSlot(str) + def set_camera(self, name: str): + """Select a camera by device name, as if chosen from the dropdown.""" + self.camera_combo.set_device(name) + + def _switch_camera(self, camera_name: str | None): + if self._camera_name is not None: + self.bec_dispatcher.disconnect_slot( + self.getting_camera_status, + MessageEndpoints.device_read_configuration(self._camera_name), + ) + self._camera_name = camera_name + self._reset_controls_to_placeholder() + + if camera_name is None: + return + + self.bec_dispatcher.connect_slot( + self.getting_camera_status, MessageEndpoints.device_read_configuration(camera_name) + ) + # device_read_configuration is retained in redis (set_and_publish), so + # seed immediately from the last known value instead of waiting for + # the camera to spontaneously republish -- connect_slot alone only + # delivers messages published *after* it connects. + try: + msg = self.client.connector.get( + MessageEndpoints.device_read_configuration(camera_name) + ) + except Exception as exc: + logger.warning(f"Failed to fetch initial config for {camera_name}: {exc}") + msg = None + if msg is not None: + self.getting_camera_status(msg.content, msg.metadata) + + self._pixel_clock_options = [] + self._last_pixel_clock_mhz = None + QTimer.singleShot(0, self._init_pixel_clock_options) + + def _reset_controls_to_placeholder(self): + self._auto_exposure_enabled = True + self.auto_exposure_toggle.blockSignals(True) + self.auto_exposure_toggle.checked = True + self.auto_exposure_toggle.blockSignals(False) + + self.exposure_time_value_label.setText("-- ms") + + self._pixel_clock_options = [] + self._last_pixel_clock_mhz = None + self.pixel_clock_slider.blockSignals(True) + self.pixel_clock_slider.setRange(0, 0) + self.pixel_clock_slider.blockSignals(False) + self.pixel_clock_value_label.setText("-- MHz") + + if self._camera_name is None: + if self.camera_combo.devices: + self.status_label.setText("No camera selected") + else: + self.status_label.setText("No IDS cameras configured") + else: + self.status_label.setText(f"Camera: {self._camera_name}") + + self._refresh_control_availability() + + def _refresh_control_availability(self): + have_camera = self._camera_name is not None + enabled = have_camera and not self._queue_busy + tooltip = "Disabled while scan queue is busy." if have_camera and self._queue_busy else "" + + self.auto_exposure_toggle.setEnabled(enabled) + self.auto_exposure_toggle.setToolTip(tooltip) + self.exposure_time_slider.setEnabled(enabled and not self._auto_exposure_enabled) + self.exposure_time_slider.setToolTip(tooltip) + self.pixel_clock_slider.setEnabled(enabled and bool(self._pixel_clock_options)) + self.pixel_clock_slider.setToolTip(tooltip) + + ################################################################################ + # Exposure / pixel clock + ################################################################################ + + @SafeSlot(dict, dict) + def getting_camera_status(self, data, meta): + if self._camera_name is None: + return + signals = data.get("signals") or {} + + auto_exp = signals.get(f"{self._camera_name}_auto_exposure_enabled") + if auto_exp is not None: + enabled = bool(auto_exp.get("value")) + self.auto_exposure_toggle.blockSignals(True) + self.auto_exposure_toggle.checked = enabled + self.auto_exposure_toggle.blockSignals(False) + self._auto_exposure_enabled = enabled + self._refresh_control_availability() + + # No auto_gain_enabled handling here -- see the comment above the + # (omitted) toggle in _init_ui. + + # Update the pixel-clock slider's position before the exposure ones + # below -- changing the pixel clock changes the exposure range, and + # both can arrive together in this same message. + pixel_clock = signals.get(f"{self._camera_name}_pixel_clock") + if pixel_clock is not None: + self._set_pixel_clock_display(int(pixel_clock.get("value"))) + + # Reseed the exposure slider's bounds next, so a value arriving in + # the same message is clamped against up-to-date min/max. Round the + # min UP and the max DOWN (never the other way) -- ceil (clamped to + # at least 1) keeps the bound physical instead of claiming 0 ms + # exposure is settable when it isn't. + exposure_min = signals.get(f"{self._camera_name}_exposure_time_min") + exposure_max = signals.get(f"{self._camera_name}_exposure_time_max") + if exposure_min is not None and exposure_max is not None: + lo = max(1, math.ceil(float(exposure_min.get("value")) * self._EXPOSURE_SLIDER_SCALE)) + hi = math.floor(float(exposure_max.get("value")) * self._EXPOSURE_SLIDER_SCALE) + if (lo, hi) != (self.exposure_time_slider.minimum(), self.exposure_time_slider.maximum()): + self.exposure_time_slider.setRange(lo, hi) + + exposure_time = signals.get(f"{self._camera_name}_exposure_time") + if exposure_time is not None: + self.exposure_time_slider.blockSignals(True) + self.exposure_time_slider.setValue( + round(float(exposure_time.get("value")) * self._EXPOSURE_SLIDER_SCALE) + ) + self.exposure_time_slider.blockSignals(False) + self._update_exposure_time_value_label(self.exposure_time_slider.value()) + + @SafeSlot(bool) + def auto_exposure_enabled_changed(self, enabled: bool): + if self._camera_name is None or self._queue_busy: + logger.warning("Ignoring auto-exposure toggle: no camera selected or queue busy.") + return + self.auto_exposure_toggle.blockSignals(True) + self.dev.get(self._camera_name).auto_exposure_enabled.put(enabled) + self.auto_exposure_toggle.checked = enabled + self.auto_exposure_toggle.blockSignals(False) + self._auto_exposure_enabled = enabled + self._refresh_control_availability() + + def _update_exposure_time_value_label(self, raw_value: int): + self.exposure_time_value_label.setText( + f"{raw_value / self._EXPOSURE_SLIDER_SCALE:.1f} ms" + ) + + def exposure_time_submitted(self): + if self._camera_name is None or self._queue_busy: + return + value_ms = self.exposure_time_slider.value() / self._EXPOSURE_SLIDER_SCALE + self.dev.get(self._camera_name).exposure_time.put(value_ms) + + def _init_pixel_clock_options(self): + """One-time-per-camera fetch of the actually-supported pixel clocks + (see IDSCamera.get_pixel_clock_list()'s docstring for why this can't + just be [min, max] from a Kind.config signal) -- not a polling loop, + this list is static per camera, so a single RPC call per camera + selection is the right cost/benefit trade-off.""" + camera_name = self._camera_name + if camera_name is None: + return + try: + options = self.dev.get(camera_name).get_pixel_clock_list() + except Exception as exc: + logger.warning(f"Failed to fetch pixel clock options for {camera_name}: {exc}") + return + if camera_name != self._camera_name: + return # camera changed again while this RPC call was in flight + self._set_pixel_clock_options(sorted(int(v) for v in options)) + + def _set_pixel_clock_options(self, options: list[int]): + self._pixel_clock_options = options + if not options: + self._refresh_control_availability() + return + self.pixel_clock_slider.blockSignals(True) + self.pixel_clock_slider.setRange(0, len(options) - 1) + self.pixel_clock_slider.blockSignals(False) + if self._last_pixel_clock_mhz is not None: + # A status message already arrived before this RPC call + # returned -- apply it now that we can actually resolve it to a + # slider position. + self._set_pixel_clock_display(self._last_pixel_clock_mhz) + else: + self._update_pixel_clock_value_label(self.pixel_clock_slider.value()) + self._refresh_control_availability() + + def _set_pixel_clock_display(self, mhz: int): + """Move the slider to the option nearest `mhz` (from hardware, via + getting_camera_status()) and update the value label -- never a raw + MHz value on the slider itself, only a valid index.""" + self._last_pixel_clock_mhz = mhz + if not self._pixel_clock_options: + # Options not fetched yet -- _set_pixel_clock_options() will + # call back into this once they arrive. + self.pixel_clock_value_label.setText(f"{mhz} MHz") + return + index = min( + range(len(self._pixel_clock_options)), + key=lambda i: abs(self._pixel_clock_options[i] - mhz), + ) + self.pixel_clock_slider.blockSignals(True) + self.pixel_clock_slider.setValue(index) + self.pixel_clock_slider.blockSignals(False) + self._update_pixel_clock_value_label(index) + + def _update_pixel_clock_value_label(self, index: int): + if 0 <= index < len(self._pixel_clock_options): + self.pixel_clock_value_label.setText(f"{self._pixel_clock_options[index]} MHz") + else: + self.pixel_clock_value_label.setText("-- MHz") + + def pixel_clock_submitted(self): + if self._camera_name is None or self._queue_busy: + return + index = self.pixel_clock_slider.value() + if not 0 <= index < len(self._pixel_clock_options): + return + self.dev.get(self._camera_name).pixel_clock.put(self._pixel_clock_options[index]) + + ################################################################################ + # Scan-queue guard + ################################################################################ + + def _update_queue_busy_state(self, busy: bool): + if busy: + self._queue_idle_timer.stop() + self._set_queue_busy(True) + return + if self._queue_busy and not self._queue_idle_timer.isActive(): + self._queue_idle_timer.start() + + def _set_queue_busy(self, busy: bool): + if busy == self._queue_busy: + return + self._queue_busy = busy + self._refresh_control_availability() + + def _release_queue_busy(self): + self._set_queue_busy(False) + + def _init_queue_status(self): + try: + msg = self.client.connector.get(MessageEndpoints.scan_queue_status()) + except Exception as exc: + logger.warning(f"Failed to fetch initial scan queue status for IDSCameraSettings: {exc}") + return + if msg is None: + return + self._update_queue_busy_state(self._is_queue_busy(msg.content)) + + @staticmethod + def _is_queue_busy(msg_content: dict) -> bool: + queues = msg_content.get("queue", {}) if isinstance(msg_content, dict) else {} + primary_queue = queues.get("primary") if isinstance(queues, dict) else None + if primary_queue is None: + return False + + queue_info = getattr(primary_queue, "info", None) + if queue_info is None and isinstance(primary_queue, dict): + queue_info = primary_queue.get("info", []) + if not queue_info: + return False + + idle_statuses = {"STOPPED", "COMPLETED", "IDLE"} + for item in queue_info: + status = getattr(item, "status", None) + if status is None and isinstance(item, dict): + status = item.get("status") + if str(status).upper() not in idle_statuses: + return True + return False + + @SafeSlot(dict, dict) + def on_queue_status_update(self, data, meta): + _ = meta + self._update_queue_busy_state(self._is_queue_busy(data)) + + def cleanup(self): + self._queue_idle_timer.stop() + if self._camera_name is not None: + self.bec_dispatcher.disconnect_slot( + self.getting_camera_status, + MessageEndpoints.device_read_configuration(self._camera_name), + ) + self.bec_dispatcher.disconnect_slot( + self.on_queue_status_update, MessageEndpoints.scan_queue_status() + ) + super().cleanup() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from bec_widgets.utils import BECDispatcher + from bec_widgets.utils.colors import apply_theme + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + apply_theme("light") + dispatcher = BECDispatcher(gui_id="ids_camera_settings") + win = IDSCameraSettings() + + win.resize(400, 250) + win.show() + sys.exit(app.exec_()) diff --git a/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.pyproject b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.pyproject new file mode 100644 index 00000000..1d5d16a7 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.pyproject @@ -0,0 +1 @@ +{'files': ['ids_camera_settings.py']} \ No newline at end of file diff --git a/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings_plugin.py b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings_plugin.py new file mode 100644 index 00000000..775cf5af --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_designer import designer_material_icon +from csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings import IDSCameraSettings + +DOM_XML = """ + + + + +""" + + +class IDSCameraSettingsPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = IDSCameraSettings(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(IDSCameraSettings.ICON_NAME) + + def includeFile(self): + return "ids_camera_settings" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "IDSCameraSettings" + + def toolTip(self): + return "Pick a configured IDS camera and adjust its exposure time / pixel clock." + + def whatsThis(self): + return self.toolTip() diff --git a/csaxs_bec/bec_widgets/widgets/ids_camera_settings/register_ids_camera_settings.py b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/register_ids_camera_settings.py new file mode 100644 index 00000000..e044a2f4 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/register_ids_camera_settings.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings_plugin import IDSCameraSettingsPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(IDSCameraSettingsPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() -- 2.54.0