From 40f53655e0498c4bae104e310f63cb5b1a6e3f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Sat, 30 May 2026 17:27:29 +0200 Subject: [PATCH 1/8] You cannot `get_data_from_shared_memory` if data is an error report --- src/instamatic/camera/camera_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/instamatic/camera/camera_client.py b/src/instamatic/camera/camera_client.py index 08536fbb..2825f05a 100644 --- a/src/instamatic/camera/camera_client.py +++ b/src/instamatic/camera/camera_client.py @@ -139,7 +139,7 @@ def _eval_dct(self, dct): else: raise RuntimeError(f'Received empty response when evaluating {dct=}') - if self.use_shared_memory and acquiring_image and data: + if status == 200 and self.use_shared_memory and acquiring_image and data: data = self.get_data_from_shared_memory(**data) if status == 200: From 881d9e3bb1567a1a6340b654328436fa000e0270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 28 Aug 2026 12:50:12 +0200 Subject: [PATCH 2/8] Implement basic interface for dectris cameras via SIMPLON --- src/instamatic/camera/camera_dectris.py | 197 ++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 src/instamatic/camera/camera_dectris.py diff --git a/src/instamatic/camera/camera_dectris.py b/src/instamatic/camera/camera_dectris.py new file mode 100644 index 00000000..ca8a995f --- /dev/null +++ b/src/instamatic/camera/camera_dectris.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import atexit +import logging +from io import BytesIO +from typing import Any, Iterator, Optional, Tuple + +import numpy as np +import requests +import tifffile + +from instamatic.camera.camera_base import CameraBase + +logger = logging.getLogger(__name__) + + +class CameraDectris(CameraBase): + """Interfaces with Dectris detectors via the SIMPLON REST-like API.""" + + streamable = True + MIN_EXPOSURE = 0.0001 + MAX_EXPOSURE = 3600.0 + + def __init__(self, name='dectris') -> None: + """Initialize camera module, vars, establish connection & cleanup.""" + super().__init__(name) + + # Base URL for the SIMPLON API (e.g., http://10.42.41.10 or http://localhost) + self.base_url = ( + getattr(self, 'url', None) or f'http://{getattr(self, "host", "localhost")}' + ) + self.api_version = getattr(self, 'api_version', '1.8.0') + + self.establish_connection() + logger.info(f'Camera {self.get_name()} initialized via SIMPLON API') + atexit.register(self.release_connection) + + def _request_url(self, module: str, task: str, endpoint: str = '') -> str: + """Construct the SIMPLON API REST URL.""" + base = self.base_url.rstrip('/') + if endpoint: + return f'{base}/{module}/api/{self.api_version}/{task}/{endpoint}' + else: + return f'{base}/{module}/api/{self.api_version}/{task}' + + def get_param(self, module: str, task: str, param: str) -> Any: + """Perform a GET request on a SIMPLON parameter resource.""" + url = self._request_url(module, task, param) + r = requests.get(url) + r.raise_for_status() + return r.json().get('value') + + def set_param(self, module: str, task: str, param: str, value: Any) -> Any: + """Perform a PUT request to configure a SIMPLON parameter resource.""" + url = self._request_url(module, task, param) + r = requests.put(url, json={'value': value}) + r.raise_for_status() + return r.json() + + def send_command(self, module: str, command: str, data: Optional[dict] = None) -> Any: + """Send a control command via a PUT request to the SIMPLON API.""" + url = self._request_url(module, 'command', command) + r = requests.put(url, json=data if data is not None else {}) + r.raise_for_status() + return r.json() if r.content else None + + def establish_connection(self) -> None: + """Establish connection, initialize detector, and apply initial + configuration.""" + logger.info(f'Connecting to Dectris detector at {self.base_url}') + + # 1. Initialize detector (mandatory once after power-up or service restart) + try: + self.send_command('detector', 'initialize') + except Exception as e: + logger.warning(f'Detector initialization notice or already initialized: {e}') + + # 2. Enable monitor interface for image retrieval + try: + self.set_param('monitor', 'config', 'mode', 'enabled') + self.set_param('monitor', 'config', 'buffer_size', 100) + self.set_param('monitor', 'config', 'discard_new', False) + except Exception as e: + logger.warning(f'Could not configure monitor interface: {e}') + + # 3. Apply detector configuration parameters from object scope / config files + detector_config = getattr(self, 'detector_config', {}) + for key, value in detector_config.items(): + try: + self.set_param('detector', 'config', key, value) + logger.debug(f'Set detector config {key} = {value}') + except Exception as e: + logger.error(f'Failed to set detector config {key} = {value}: {e}') + + def release_connection(self) -> None: + """Release connection, disarm the detector, and disable the monitor.""" + try: + self.send_command('detector', 'disarm') + except Exception: + pass + try: + self.set_param('monitor', 'config', 'mode', 'disabled') + except Exception: + pass + logger.info(f"Connection to camera '{self.get_name()}' released") + + def get_image(self, exposure: Optional[float] = None, **kwargs) -> np.ndarray: + """Acquire a single image with the specified exposure time.""" + e = self.default_exposure if exposure is None else exposure + + if e < self.MIN_EXPOSURE or e > self.MAX_EXPOSURE: + raise ValueError( + f'Requested exposure {e}s is out of supported range ({self.MIN_EXPOSURE}-{self.MAX_EXPOSURE}s)' + ) + + logger.debug(f'Collecting single image with exposure {e} s') + + # Configure acquisition parameters + self.set_param('detector', 'config', 'count_time', e) + self.set_param('detector', 'config', 'frame_time', e + 0.001) + self.set_param('detector', 'config', 'nimages', 1) + self.set_param('detector', 'config', 'ntrigger', 1) + self.set_param('detector', 'config', 'trigger_mode', 'ints') + + # Clear monitor buffer + try: + self.send_command('monitor', 'clear') + except Exception: + pass + + # Arm and trigger acquisition + self.send_command('detector', 'arm') + self.send_command('detector', 'trigger') + + # Retrieve image from the monitor interface as TIFF + url = self._request_url('monitor', 'images', 'next') + '?timeout=5000' + r = requests.get(url, headers={'Accept': 'image/tiff'}) + r.raise_for_status() + image = tifffile.imread(BytesIO(r.content)) + + # Disarm detector + try: + self.send_command('detector', 'disarm') + except Exception: + pass + + return image + + def get_movie( + self, n_frames: int, exposure: Optional[float] = None, **kwargs + ) -> Iterator[np.ndarray]: + """Acquire a movie of `n_frames` with the specified exposure time.""" + e = self.default_exposure if exposure is None else exposure + + logger.debug(f'Collecting {n_frames}-frame movie with exposure {e} s') + + self.set_param('detector', 'config', 'count_time', e) + self.set_param('detector', 'config', 'frame_time', e + 0.001) + self.set_param('detector', 'config', 'nimages', n_frames) + self.set_param('detector', 'config', 'ntrigger', 1) + self.set_param('detector', 'config', 'trigger_mode', 'ints') + + try: + self.send_command('monitor', 'clear') + except Exception: + pass + + self.send_command('detector', 'arm') + self.send_command('detector', 'trigger') + + try: + for _ in range(n_frames): + url = self._request_url('monitor', 'images', 'next') + '?timeout=10000' + r = requests.get(url, headers={'Accept': 'image/tiff'}) + r.raise_for_status() + yield tifffile.imread(BytesIO(r.content)) + finally: + try: + self.send_command('detector', 'disarm') + except Exception as ex: + logger.error(f'Error disarming detector after movie: {ex}') + + def get_image_dimensions(self) -> Tuple[int, int]: + """Get the binned dimensions reported by the camera.""" + binning = self.get_binning() + try: + dim_x = int(self.get_param('detector', 'config', 'x_pixels_in_detector')) + dim_y = int(self.get_param('detector', 'config', 'y_pixels_in_detector')) + except Exception: + dim_x, dim_y = self.get_camera_dimensions() + return int(dim_x / binning), int(dim_y / binning) + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + cam = CameraDectris() + print('Dectris camera initialized successfully.') From 9ef64c0ad688b5991c2c8191c0351833d5235c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 28 Aug 2026 15:54:01 +0200 Subject: [PATCH 3/8] Adapt instamatic around the new DECTRIS SIMPLON interface --- pyproject.toml | 1 + readme.md | 1 + requirements.txt | 1 + src/instamatic/camera/camera.py | 2 + src/instamatic/camera/camera_dectris.py | 197 ---------------------- src/instamatic/camera/camera_simplon.py | 154 +++++++++++++++++ src/instamatic/config/camera/simplon.yaml | 24 +++ 7 files changed, 183 insertions(+), 197 deletions(-) delete mode 100644 src/instamatic/camera/camera_dectris.py create mode 100644 src/instamatic/camera/camera_simplon.py create mode 100644 src/instamatic/config/camera/simplon.yaml diff --git a/pyproject.toml b/pyproject.toml index 722996cf..68a11bbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dependencies = [ "pillow >= 7.0.0", "pywinauto >= 0.6.8; sys_platform == 'windows'", "pyyaml >= 5.3", + "requests >= 2.0.0", "scikit-image >= 0.17.1", "scipy >= 1.3.2", "tifffile >= 2019.7.26.2", diff --git a/readme.md b/readme.md index 614d8dce..e152a481 100644 --- a/readme.md +++ b/readme.md @@ -21,6 +21,7 @@ Cameras supported: - ASI Timepix - ASI CheeTah through `serval-toolkit` library +- DECTRIS cameras through SIMPLON API - TVIPS cameras through EMMENU4 API - Quantum Detectors MerlinEM - Gatan cameras through FEI scripting interface diff --git a/requirements.txt b/requirements.txt index e338809c..651dce59 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ pandas >= 1.0.0 pillow >= 7.0.0 pywinauto >= 0.6.8; sys_platform == 'windows' pyyaml >= 5.3 +requests >= 2.0.0 scikit-image >= 0.17.1 scipy >= 1.3.2 tifffile >= 2019.7.26.2 diff --git a/src/instamatic/camera/camera.py b/src/instamatic/camera/camera.py index 4deba61f..f64a5ab0 100644 --- a/src/instamatic/camera/camera.py +++ b/src/instamatic/camera/camera.py @@ -33,6 +33,8 @@ def get_camera_class(interface: str) -> type[CameraBase]: from instamatic.camera.camera_emmenu import CameraEMMENU as cam elif interface == 'serval': from instamatic.camera.camera_serval import CameraServal as cam + elif interface == 'simplon': + from instamatic.camera.camera_simplon import CameraSimplon as cam elif interface == 'merlin': from instamatic.camera.camera_merlin import CameraMerlin as cam else: diff --git a/src/instamatic/camera/camera_dectris.py b/src/instamatic/camera/camera_dectris.py deleted file mode 100644 index ca8a995f..00000000 --- a/src/instamatic/camera/camera_dectris.py +++ /dev/null @@ -1,197 +0,0 @@ -from __future__ import annotations - -import atexit -import logging -from io import BytesIO -from typing import Any, Iterator, Optional, Tuple - -import numpy as np -import requests -import tifffile - -from instamatic.camera.camera_base import CameraBase - -logger = logging.getLogger(__name__) - - -class CameraDectris(CameraBase): - """Interfaces with Dectris detectors via the SIMPLON REST-like API.""" - - streamable = True - MIN_EXPOSURE = 0.0001 - MAX_EXPOSURE = 3600.0 - - def __init__(self, name='dectris') -> None: - """Initialize camera module, vars, establish connection & cleanup.""" - super().__init__(name) - - # Base URL for the SIMPLON API (e.g., http://10.42.41.10 or http://localhost) - self.base_url = ( - getattr(self, 'url', None) or f'http://{getattr(self, "host", "localhost")}' - ) - self.api_version = getattr(self, 'api_version', '1.8.0') - - self.establish_connection() - logger.info(f'Camera {self.get_name()} initialized via SIMPLON API') - atexit.register(self.release_connection) - - def _request_url(self, module: str, task: str, endpoint: str = '') -> str: - """Construct the SIMPLON API REST URL.""" - base = self.base_url.rstrip('/') - if endpoint: - return f'{base}/{module}/api/{self.api_version}/{task}/{endpoint}' - else: - return f'{base}/{module}/api/{self.api_version}/{task}' - - def get_param(self, module: str, task: str, param: str) -> Any: - """Perform a GET request on a SIMPLON parameter resource.""" - url = self._request_url(module, task, param) - r = requests.get(url) - r.raise_for_status() - return r.json().get('value') - - def set_param(self, module: str, task: str, param: str, value: Any) -> Any: - """Perform a PUT request to configure a SIMPLON parameter resource.""" - url = self._request_url(module, task, param) - r = requests.put(url, json={'value': value}) - r.raise_for_status() - return r.json() - - def send_command(self, module: str, command: str, data: Optional[dict] = None) -> Any: - """Send a control command via a PUT request to the SIMPLON API.""" - url = self._request_url(module, 'command', command) - r = requests.put(url, json=data if data is not None else {}) - r.raise_for_status() - return r.json() if r.content else None - - def establish_connection(self) -> None: - """Establish connection, initialize detector, and apply initial - configuration.""" - logger.info(f'Connecting to Dectris detector at {self.base_url}') - - # 1. Initialize detector (mandatory once after power-up or service restart) - try: - self.send_command('detector', 'initialize') - except Exception as e: - logger.warning(f'Detector initialization notice or already initialized: {e}') - - # 2. Enable monitor interface for image retrieval - try: - self.set_param('monitor', 'config', 'mode', 'enabled') - self.set_param('monitor', 'config', 'buffer_size', 100) - self.set_param('monitor', 'config', 'discard_new', False) - except Exception as e: - logger.warning(f'Could not configure monitor interface: {e}') - - # 3. Apply detector configuration parameters from object scope / config files - detector_config = getattr(self, 'detector_config', {}) - for key, value in detector_config.items(): - try: - self.set_param('detector', 'config', key, value) - logger.debug(f'Set detector config {key} = {value}') - except Exception as e: - logger.error(f'Failed to set detector config {key} = {value}: {e}') - - def release_connection(self) -> None: - """Release connection, disarm the detector, and disable the monitor.""" - try: - self.send_command('detector', 'disarm') - except Exception: - pass - try: - self.set_param('monitor', 'config', 'mode', 'disabled') - except Exception: - pass - logger.info(f"Connection to camera '{self.get_name()}' released") - - def get_image(self, exposure: Optional[float] = None, **kwargs) -> np.ndarray: - """Acquire a single image with the specified exposure time.""" - e = self.default_exposure if exposure is None else exposure - - if e < self.MIN_EXPOSURE or e > self.MAX_EXPOSURE: - raise ValueError( - f'Requested exposure {e}s is out of supported range ({self.MIN_EXPOSURE}-{self.MAX_EXPOSURE}s)' - ) - - logger.debug(f'Collecting single image with exposure {e} s') - - # Configure acquisition parameters - self.set_param('detector', 'config', 'count_time', e) - self.set_param('detector', 'config', 'frame_time', e + 0.001) - self.set_param('detector', 'config', 'nimages', 1) - self.set_param('detector', 'config', 'ntrigger', 1) - self.set_param('detector', 'config', 'trigger_mode', 'ints') - - # Clear monitor buffer - try: - self.send_command('monitor', 'clear') - except Exception: - pass - - # Arm and trigger acquisition - self.send_command('detector', 'arm') - self.send_command('detector', 'trigger') - - # Retrieve image from the monitor interface as TIFF - url = self._request_url('monitor', 'images', 'next') + '?timeout=5000' - r = requests.get(url, headers={'Accept': 'image/tiff'}) - r.raise_for_status() - image = tifffile.imread(BytesIO(r.content)) - - # Disarm detector - try: - self.send_command('detector', 'disarm') - except Exception: - pass - - return image - - def get_movie( - self, n_frames: int, exposure: Optional[float] = None, **kwargs - ) -> Iterator[np.ndarray]: - """Acquire a movie of `n_frames` with the specified exposure time.""" - e = self.default_exposure if exposure is None else exposure - - logger.debug(f'Collecting {n_frames}-frame movie with exposure {e} s') - - self.set_param('detector', 'config', 'count_time', e) - self.set_param('detector', 'config', 'frame_time', e + 0.001) - self.set_param('detector', 'config', 'nimages', n_frames) - self.set_param('detector', 'config', 'ntrigger', 1) - self.set_param('detector', 'config', 'trigger_mode', 'ints') - - try: - self.send_command('monitor', 'clear') - except Exception: - pass - - self.send_command('detector', 'arm') - self.send_command('detector', 'trigger') - - try: - for _ in range(n_frames): - url = self._request_url('monitor', 'images', 'next') + '?timeout=10000' - r = requests.get(url, headers={'Accept': 'image/tiff'}) - r.raise_for_status() - yield tifffile.imread(BytesIO(r.content)) - finally: - try: - self.send_command('detector', 'disarm') - except Exception as ex: - logger.error(f'Error disarming detector after movie: {ex}') - - def get_image_dimensions(self) -> Tuple[int, int]: - """Get the binned dimensions reported by the camera.""" - binning = self.get_binning() - try: - dim_x = int(self.get_param('detector', 'config', 'x_pixels_in_detector')) - dim_y = int(self.get_param('detector', 'config', 'y_pixels_in_detector')) - except Exception: - dim_x, dim_y = self.get_camera_dimensions() - return int(dim_x / binning), int(dim_y / binning) - - -if __name__ == '__main__': - logging.basicConfig(level=logging.INFO) - cam = CameraDectris() - print('Dectris camera initialized successfully.') diff --git a/src/instamatic/camera/camera_simplon.py b/src/instamatic/camera/camera_simplon.py new file mode 100644 index 00000000..c9901c0d --- /dev/null +++ b/src/instamatic/camera/camera_simplon.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import atexit +import logging +from io import BytesIO +from typing import Any, Generator, Optional, Tuple + +import numpy as np +import requests +import tifffile + +from instamatic.camera.camera_base import CameraBase + +logger = logging.getLogger(__name__) + + +class CameraSimplon(CameraBase): + """Interfaces with Dectris detectors via the SIMPLON REST-like API.""" + + streamable = True + MIN_EXPOSURE = 0.0001 + MAX_EXPOSURE = 60.0 + BAD_EXPOSURE_MSG = 'Requested exposure is out of supported range (>0-1m)' + + def __init__(self, name='dectris') -> None: + """Initialize camera module, vars, establish connection & cleanup.""" + super().__init__(name) + + self.base_url = getattr(self, 'url', 'http://localhost:8000/') + self.api_version = getattr(self, 'api_version', '1.8.0') + + self.establish_connection() + logger.info(f'Camera {self.get_name()} initialized via SIMPLON API') + atexit.register(self.release_connection) + + def _request_url(self, module: str, *params: str, timeout: int = 0) -> str: + """Construct the SIMPLON API REST URL from params (and timeout).""" + base = self.base_url.rstrip('/') + url = f'{base}/{module}/api/{self.api_version}/' + '/'.join(params) + url += f'?timeout={int(timeout)}' if timeout else '' + return url + + def get(self, module: str, *params: str, timeout: int = 0) -> Any: + """Perform a GET request on a SIMPLON parameter resource.""" + url = self._request_url(module, *params, timeout=timeout) + r = requests.get(url) + r.raise_for_status() + return r.json().get('value') + + def put(self, module: str, *params: str, timeout: int = 0, value=None) -> Any: + """Perform a PUT request to configure a SIMPLON parameter resource.""" + url = self._request_url(module, *params, timeout=timeout) + r = requests.put(url, json={} if value is None else {'value': value}) + r.raise_for_status() + return r.json() + + def establish_connection(self) -> None: + """Establish connection, initialize detector, apply initial config.""" + logger.info(f'Connecting to SIMPLON detector at {self.base_url}') + + try: + self.put('detector', 'command', 'initialize') + except Exception as e: + logger.warning(f'Initialization failed or already initialized: {e}') + + try: + self.put('monitor', 'config', 'mode', 'enabled') + self.put('monitor', 'config', 'buffer_size', value=100) + self.put('monitor', 'config', 'discard_new', value=False) + except Exception as e: + logger.warning(f'Could not configure monitor interface: {e}') + + detector_config = getattr(self, 'detector_config', {}) + for key, value in detector_config.items(): + try: + self.put('detector', 'config', key, value=value) + logger.debug(f'Set detector config {key} = {value}') + except Exception as e: + logger.error(f'Failed to set detector config {key} = {value}: {e}') + + def release_connection(self) -> None: + """Release connection, disarm the detector, disable the monitor.""" + try: + self.put('detector', 'command', 'disarm') + except Exception: + pass + try: + self.put('monitor', 'config', 'mode', value='disabled') + except Exception: + pass + logger.info(f"Connection to camera '{self.get_name()}' released") + + def get_image(self, exposure: Optional[float] = None, **kwargs) -> np.ndarray: + """Acquire a single image with specified exposure time via monitor.""" + return list(self.get_movie(1, exposure=exposure))[0] + + def get_movie( + self, + n_frames: int = 1, + exposure: Optional[float] = None, + **kwargs: Any, + ) -> Generator[np.ndarray]: + """Eagerly configure, lazily yield `n_frames` with `exposure`.""" + e = self.default_exposure if exposure is None else exposure + + if e < self.MIN_EXPOSURE or e > self.MAX_EXPOSURE: + raise ValueError(f'{self.BAD_EXPOSURE_MSG}: {e}s') + logger.debug(f'Collecting {n_frames} images with exposure {e} s') + + self.put('detector', 'config', 'count_time', value=e) + self.put('detector', 'config', 'nimages', value=n_frames) + self.put('detector', 'config', 'ntrigger', value=1) + self.put('detector', 'config', 'trigger_mode', value='ints') + + try: + self.put('monitor', 'command', 'clear') + except Exception: + pass + + self.put('detector', 'command', 'arm') + timeout = int(self.MAX_EXPOSURE) + url = self._request_url('monitor', 'images', 'next', timeout=timeout) + + def _get_movie_inner(_n: int) -> Generator[np.ndarray]: + """This data collection is executed lazily i.e. when iterating.""" + self.put('detector', 'command', 'trigger') + try: + for _ in range(_n): + r = requests.get(url, headers={'Accept': 'image/tiff'}) + r.raise_for_status() + yield tifffile.imread(BytesIO(r.content)) + finally: + try: + self.put('detector', 'command', 'disarm') + except Exception as exc: + logger.error(f'Error disarming detector after movie: {exc}') + + return _get_movie_inner(n_frames) + + def get_image_dimensions(self) -> Tuple[int, int]: + """Get the binned dimensions reported by the camera.""" + binning = self.get_binning() + try: + dim_x = int(self.get('detector', 'config', 'x_pixels_in_detector')) + dim_y = int(self.get('detector', 'config', 'y_pixels_in_detector')) + except Exception: + dim_x, dim_y = self.get_camera_dimensions() + return int(dim_x / binning), int(dim_y / binning) + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + cam = CameraSimplon() + print('SIMPLON camera initialized successfully.') diff --git a/src/instamatic/config/camera/simplon.yaml b/src/instamatic/config/camera/simplon.yaml new file mode 100644 index 00000000..c1648121 --- /dev/null +++ b/src/instamatic/config/camera/simplon.yaml @@ -0,0 +1,24 @@ +camera_rotation_vs_stage_xy: 0.0 +default_binsize: 1 +default_exposure: 0.05 + +dimensions: [1030, 1065] # example dimensions for EIGER2 + +dynamic_range: 11800 +interface: simplon +physical_pixelsize: 0.075 # pixel size in mm for EIGER2 +possible_binsizes: [1] + +stretch_amplitude: 0.0 +stretch_azimuth: 0.0 + +url: 'http://localhost:8080' +api_version: '1.8.0' + +detector_config: + auto_summation: True + countrate_correction_applied: True + flatfield_correction_applied: + trigger_mode: 'ints' + photon_energy: 200000.0 + pixel_mask_applied: True From 984923360bf06008dea1a3281402b440c2b9dad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 28 Aug 2026 17:20:55 +0200 Subject: [PATCH 4/8] Simplified config setup after tests with ansto-simplon-api --- src/instamatic/camera/camera_simplon.py | 2 -- src/instamatic/config/camera/simplon.yaml | 4 ---- 2 files changed, 6 deletions(-) diff --git a/src/instamatic/camera/camera_simplon.py b/src/instamatic/camera/camera_simplon.py index c9901c0d..d17dfedf 100644 --- a/src/instamatic/camera/camera_simplon.py +++ b/src/instamatic/camera/camera_simplon.py @@ -109,8 +109,6 @@ def get_movie( self.put('detector', 'config', 'count_time', value=e) self.put('detector', 'config', 'nimages', value=n_frames) - self.put('detector', 'config', 'ntrigger', value=1) - self.put('detector', 'config', 'trigger_mode', value='ints') try: self.put('monitor', 'command', 'clear') diff --git a/src/instamatic/config/camera/simplon.yaml b/src/instamatic/config/camera/simplon.yaml index c1648121..8566515c 100644 --- a/src/instamatic/config/camera/simplon.yaml +++ b/src/instamatic/config/camera/simplon.yaml @@ -16,9 +16,5 @@ url: 'http://localhost:8080' api_version: '1.8.0' detector_config: - auto_summation: True - countrate_correction_applied: True - flatfield_correction_applied: trigger_mode: 'ints' photon_energy: 200000.0 - pixel_mask_applied: True From c127e771c9674bde5bb9f268190114b6f86ac92c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Fri, 28 Aug 2026 18:01:00 +0200 Subject: [PATCH 5/8] Change default parameters to these of DECTRIS QUADRO --- src/instamatic/config/camera/simplon.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/instamatic/config/camera/simplon.yaml b/src/instamatic/config/camera/simplon.yaml index 8566515c..4fe6c628 100644 --- a/src/instamatic/config/camera/simplon.yaml +++ b/src/instamatic/config/camera/simplon.yaml @@ -2,11 +2,11 @@ camera_rotation_vs_stage_xy: 0.0 default_binsize: 1 default_exposure: 0.05 -dimensions: [1030, 1065] # example dimensions for EIGER2 +dimensions: [512, 512] # example dimensions for QUADRO dynamic_range: 11800 interface: simplon -physical_pixelsize: 0.075 # pixel size in mm for EIGER2 +physical_pixelsize: 0.075 # pixel size in mm for QUADRO possible_binsizes: [1] stretch_amplitude: 0.0 From 4eedd13531f3700779a363e05403aecd860eee7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Mon, 31 Aug 2026 15:12:44 +0200 Subject: [PATCH 6/8] Apply changes from today's debugging session --- src/instamatic/camera/camera_simplon.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/instamatic/camera/camera_simplon.py b/src/instamatic/camera/camera_simplon.py index d17dfedf..c0ca3135 100644 --- a/src/instamatic/camera/camera_simplon.py +++ b/src/instamatic/camera/camera_simplon.py @@ -3,6 +3,7 @@ import atexit import logging from io import BytesIO +from json.decoder import JSONDecodeError from typing import Any, Generator, Optional, Tuple import numpy as np @@ -47,12 +48,15 @@ def get(self, module: str, *params: str, timeout: int = 0) -> Any: r.raise_for_status() return r.json().get('value') - def put(self, module: str, *params: str, timeout: int = 0, value=None) -> Any: + def put(self, module: str, *params: str, timeout: int = 0, value=None) -> dict: """Perform a PUT request to configure a SIMPLON parameter resource.""" url = self._request_url(module, *params, timeout=timeout) r = requests.put(url, json={} if value is None else {'value': value}) r.raise_for_status() - return r.json() + try: + return r.json() + except JSONDecodeError: + return {} def establish_connection(self) -> None: """Establish connection, initialize detector, apply initial config.""" @@ -63,12 +67,9 @@ def establish_connection(self) -> None: except Exception as e: logger.warning(f'Initialization failed or already initialized: {e}') - try: - self.put('monitor', 'config', 'mode', 'enabled') - self.put('monitor', 'config', 'buffer_size', value=100) - self.put('monitor', 'config', 'discard_new', value=False) - except Exception as e: - logger.warning(f'Could not configure monitor interface: {e}') + self.put('monitor', 'config', 'mode', value='enabled') + self.put('monitor', 'config', 'buffer_size', value=100) + self.put('monitor', 'config', 'discard_new', value=False) detector_config = getattr(self, 'detector_config', {}) for key, value in detector_config.items(): From 360648cb724cd537bfdc4d5d9ffbd1821e956b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Wed, 2 Sep 2026 14:46:11 +0200 Subject: [PATCH 7/8] Add `_wait4idle`, exception handling to `_get_movie_inner`, remove `disarm` redundant in `ints` mode --- src/instamatic/camera/camera_simplon.py | 39 +++++++++++++++++++++---- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/src/instamatic/camera/camera_simplon.py b/src/instamatic/camera/camera_simplon.py index c0ca3135..dcbb4de9 100644 --- a/src/instamatic/camera/camera_simplon.py +++ b/src/instamatic/camera/camera_simplon.py @@ -2,6 +2,8 @@ import atexit import logging +import time +from concurrent.futures import ThreadPoolExecutor from io import BytesIO from json.decoder import JSONDecodeError from typing import Any, Generator, Optional, Tuple @@ -32,6 +34,9 @@ def __init__(self, name='dectris') -> None: self.establish_connection() logger.info(f'Camera {self.get_name()} initialized via SIMPLON API') + + te_name = 'simplon-trigger' + self._te = ThreadPoolExecutor(max_workers=1, thread_name_prefix=te_name) atexit.register(self.release_connection) def _request_url(self, module: str, *params: str, timeout: int = 0) -> str: @@ -41,6 +46,15 @@ def _request_url(self, module: str, *params: str, timeout: int = 0) -> str: url += f'?timeout={int(timeout)}' if timeout else '' return url + def _wait4idle(self, timeout=5.0): + """Wait until the camera is idle, or raise if timeout reached.""" + t0 = time.time() + while self.get('detector', 'status', 'state') != 'idle': + if time.time() - t0 > timeout: + m = 'Detector not idle - another client may be controlling it' + raise RuntimeError(m) + time.sleep(0.1) + def get(self, module: str, *params: str, timeout: int = 0) -> Any: """Perform a GET request on a SIMPLON parameter resource.""" url = self._request_url(module, *params, timeout=timeout) @@ -70,6 +84,7 @@ def establish_connection(self) -> None: self.put('monitor', 'config', 'mode', value='enabled') self.put('monitor', 'config', 'buffer_size', value=100) self.put('monitor', 'config', 'discard_new', value=False) + self.put('detector', 'config', 'trigger_mode', value='ints') detector_config = getattr(self, 'detector_config', {}) for key, value in detector_config.items(): @@ -89,6 +104,7 @@ def release_connection(self) -> None: self.put('monitor', 'config', 'mode', value='disabled') except Exception: pass + self._te.shutdown(wait=False) logger.info(f"Connection to camera '{self.get_name()}' released") def get_image(self, exposure: Optional[float] = None, **kwargs) -> np.ndarray: @@ -108,6 +124,7 @@ def get_movie( raise ValueError(f'{self.BAD_EXPOSURE_MSG}: {e}s') logger.debug(f'Collecting {n_frames} images with exposure {e} s') + self._wait4idle() self.put('detector', 'config', 'count_time', value=e) self.put('detector', 'config', 'nimages', value=n_frames) @@ -117,22 +134,32 @@ def get_movie( pass self.put('detector', 'command', 'arm') - timeout = int(self.MAX_EXPOSURE) + timeout = int(1000 * (e * 2 + 1)) # s -> ms url = self._request_url('monitor', 'images', 'next', timeout=timeout) def _get_movie_inner(_n: int) -> Generator[np.ndarray]: """This data collection is executed lazily i.e. when iterating.""" - self.put('detector', 'command', 'trigger') + t_future = self._te.submit(self.put, 'detector', 'command', 'trigger') try: - for _ in range(_n): + for i in range(_n): r = requests.get(url, headers={'Accept': 'image/tiff'}) + if r.status_code == 408: + msg = f'No frame {i}/{_n} received within {timeout}ms' + raise TimeoutError(msg) r.raise_for_status() yield tifffile.imread(BytesIO(r.content)) - finally: + t_future.result() # propagate any late exception, join thread + except Exception: try: - self.put('detector', 'command', 'disarm') + self.put('detector', 'command', 'abort') except Exception as exc: - logger.error(f'Error disarming detector after movie: {exc}') + logger.error(f'Error aborting after failed movie: {exc}') + if not t_future.done(): + try: + t_future.result(timeout=_n * timeout / 1000) + except Exception as exc: + logger.warning(f'Trigger did not return after abort: {exc}') + raise return _get_movie_inner(n_frames) From 00627aecec3321b647bdad864c89cd8979018a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tcho=C5=84?= Date: Thu, 3 Sep 2026 14:39:30 +0200 Subject: [PATCH 8/8] Add example `calib_beamshift` calibration values to `simplon.yaml` --- src/instamatic/config/camera/simplon.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/instamatic/config/camera/simplon.yaml b/src/instamatic/config/camera/simplon.yaml index 4fe6c628..25aa269d 100644 --- a/src/instamatic/config/camera/simplon.yaml +++ b/src/instamatic/config/camera/simplon.yaml @@ -4,6 +4,11 @@ default_exposure: 0.05 dimensions: [512, 512] # example dimensions for QUADRO +calib_beamshift: + gridsize: 5 + stepsize: 2000 + delay: 0.1 + dynamic_range: 11800 interface: simplon physical_pixelsize: 0.075 # pixel size in mm for QUADRO