From 11543579216586f0ab5aa0054990e5f6058cd09c Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Fri, 3 Apr 2026 13:13:55 +0200 Subject: [PATCH 1/6] Add Aravis camera driver as PySpin alternative for Cytation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pure-Aravis alternative to PySpin for controlling BlackFly cameras in the BioTek Cytation. Eliminates the Spinnaker SDK dependency and the Python 3.10 version cap that PySpin imposes. New files: - aravis_camera.py: standalone BlackFly driver using Aravis/GenICam via PyGObject - aravis_simulated.py: simulated camera for testing without hardware - cytation_aravis.py: CytationAravisBackend — drop-in replacement for CytationBackend using AravisCamera instead of PySpin. Same serial protocol for optics (filters, objectives, focus, LED), different camera driver. capture() signature matches MicroscopyBackend ABC with backend_params. - ARAVIS_README.md: installation and usage guide - test_aravis_connection.ipynb: hardware test notebook Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/agilent/biotek/ARAVIS_README.md | 142 ++++ pylabrobot/agilent/biotek/aravis_camera.py | 467 ++++++++++++ pylabrobot/agilent/biotek/aravis_simulated.py | 213 ++++++ pylabrobot/agilent/biotek/cytation_aravis.py | 687 ++++++++++++++++++ .../biotek/test_aravis_connection.ipynb | 539 ++++++++++++++ 5 files changed, 2048 insertions(+) create mode 100644 pylabrobot/agilent/biotek/ARAVIS_README.md create mode 100644 pylabrobot/agilent/biotek/aravis_camera.py create mode 100644 pylabrobot/agilent/biotek/aravis_simulated.py create mode 100644 pylabrobot/agilent/biotek/cytation_aravis.py create mode 100644 pylabrobot/agilent/biotek/test_aravis_connection.ipynb diff --git a/pylabrobot/agilent/biotek/ARAVIS_README.md b/pylabrobot/agilent/biotek/ARAVIS_README.md new file mode 100644 index 00000000000..a97399660bc --- /dev/null +++ b/pylabrobot/agilent/biotek/ARAVIS_README.md @@ -0,0 +1,142 @@ +# Aravis Camera Driver for PLR's Cytation + +**Status**: Proof-of-concept, tested on real hardware (Cytation 1 + BlackFly BFLY-U3-13S2M) + +Replaces PySpin with [Aravis](https://github.com/AravisProject/aravis) for controlling the BlackFly camera inside the Cytation. No Spinnaker SDK needed. No Python version cap. + +## Why + +PLR's `CytationBackend` uses PySpin (FLIR Spinnaker SDK) for camera control. PySpin only has pre-built wheels up to Python 3.10, blocking PLR from using newer Python versions. Aravis is an open-source alternative that talks to the camera directly via GenICam/USB3 Vision — no vendor SDK at all. + +## What's Here + +``` +aravis_camera.py # Standalone camera driver (Aravis/GenICam) +aravis_simulated.py # Simulated camera for testing without hardware +cytation_aravis.py # CytationAravisBackend — drop-in for CytationBackend +``` + +### aravis_camera.py + +Standalone camera driver. No PLR dependencies (just numpy). Handles: +- Camera discovery by serial number +- Software trigger (single-frame capture) +- Exposure/gain control via GenICam nodes +- Buffer management (pre-allocated pool) +- Clean connect/disconnect + +Can be used independently or composed into `CytationAravisBackend`. + +### aravis_simulated.py + +Same API as `AravisCamera` but returns synthetic numpy arrays. No Aravis, no GObject, no hardware needed. For testing and CI. + +### cytation_aravis.py + +`CytationAravisBackend(BioTekBackend, MicroscopyBackend)` — uses `AravisCamera` for the camera and PLR's `BioTekBackend` for the Cytation serial protocol (filter wheel, objective turret, focus motor, LED, stage positioning). Drop-in replacement for `CytationBackend`. + +## Installation + +### 1. Aravis system library + +```bash +# macOS +brew install aravis + +# Linux +sudo apt-get install libaravis-dev gir1.2-aravis-0.8 +``` + +### 2. Python dependencies + +```bash +pip install PyGObject numpy +``` + +### 3. Drop files into PLR + +Copy the three `.py` files to `pylabrobot/agilent/biotek/` in your PLR installation. + +## Usage + +### Standalone (camera only, no Cytation serial) + +```python +from aravis_camera import AravisCamera + +camera = AravisCamera() + +# Discover +cameras = AravisCamera.enumerate_cameras() +print(cameras) # [CameraInfo(serial='...', model='Blackfly ...', ...)] + +# Connect, capture, disconnect +await camera.setup(cameras[0].serial_number) +await camera.set_exposure(10.0) # ms +await camera.set_gain(1.0) +image = await camera.trigger() # numpy array (height, width), uint8 +print(image.shape) # (964, 1288) +await camera.stop() +``` + +### With PLR Cytation backend + +```python +from pylabrobot.agilent.biotek.cytation_aravis import CytationAravisBackend +from pylabrobot.capabilities.microscopy.standard import ImagingMode, Objective + +backend = CytationAravisBackend(camera_serial="your_serial_here") +await backend.setup(use_cam=True) + +# Same capture API as CytationBackend — serial protocol + camera +res = await backend.capture( + row=1, column=1, + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_4X_PL_FL, + focal_height=3.0, + exposure_time=5, + gain=16, + plate=your_plate, + led_intensity=5, +) + +# res.images[0] is a numpy array +await backend.stop() +``` + +## What It Replaces + +``` +BEFORE: AFTER: +CytationBackend CytationAravisBackend + ├─ serial protocol (BioTek) ├─ serial protocol (same) + └─ PySpin → Spinnaker SDK └─ AravisCamera → Aravis + (proprietary, Python ≤3.10) (open source, any Python) +``` + +Only the camera layer changes. The Cytation serial protocol (filter wheel, objectives, focus, LED, stage) is identical. + +## Tested On + +- Cytation 1 (firmware 1.02) +- BlackFly BFLY-U3-13S2M (USB3 Vision) +- Aravis 0.8.35 +- macOS, Python 3.12 +- Camera acquisition, exposure/gain control, LED, stage movement, focus motor — all working +- Gen5 (BioTek software) still works after Aravis testing — no damage to instrument state + +## Known Gotchas + +1. **1-second delay after trigger mode change** — BlackFly cameras need `asyncio.sleep(1)` after setting TriggerMode=On. Already handled in `AravisCamera.setup()`. + +2. **Don't open camera during enumeration** — `Aravis.Camera.new()` locks the USB device. `enumerate_cameras()` uses lightweight descriptor reads instead. + +3. **Camera serial has two formats** — Aravis device list returns hex (e.g., `010B6B10`), GenICam returns decimal (`17525520`). `setup()` handles both. + +## PLR Branch + +Built against PLR's `capability-architecture` branch (commit 226e6d41). Import paths differ from released PLR (`main`). See the analysis doc for details. + +--- + +*Built with [Claude Code](https://claude.ai/claude-code) by Vincent de Boer* diff --git a/pylabrobot/agilent/biotek/aravis_camera.py b/pylabrobot/agilent/biotek/aravis_camera.py new file mode 100644 index 00000000000..acfebdb011e --- /dev/null +++ b/pylabrobot/agilent/biotek/aravis_camera.py @@ -0,0 +1,467 @@ +"""Standalone BlackFly camera driver using Aravis (GenICam/USB3 Vision). + +Layer: Camera driver (standalone, no PLR dependencies except numpy) +Role: Replaces PySpin for image acquisition from FLIR/Teledyne BlackFly cameras. +Adjacent layers: + - Above: CytationAravisBackend delegates camera operations here + - Below: Aravis library (via PyGObject) talks to camera via USB3 Vision/GenICam + +This module provides a pure-Aravis alternative to PySpin for controlling BlackFly +cameras. It eliminates the Spinnaker SDK dependency and the Python 3.10 version cap +that PySpin imposes. Aravis talks directly to the camera via the GenICam standard +over USB3 Vision — no vendor runtime needed. + +Architecture label: **[Proposed]** — Aravis as alternative to PySpin for PLR. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import Optional + +import numpy as np + +logger = logging.getLogger(__name__) + +# Aravis is an optional dependency. It requires: +# - System library: brew install aravis (macOS) or apt install libaravis-dev (Linux) +# - Python bindings: pip install PyGObject +# If not installed, AravisCamera.setup() will raise ImportError with instructions. +try: + import gi + gi.require_version("Aravis", "0.8") + from gi.repository import Aravis # type: ignore[attr-defined] + HAS_ARAVIS = True +except (ImportError, ValueError): + HAS_ARAVIS = False + Aravis = None # type: ignore[assignment] + +# Number of pre-allocated buffers for the Aravis stream. +# For single-frame software-triggered capture, 5 is more than sufficient. +_BUFFER_COUNT = 5 + + +@dataclass +class CameraInfo: + """Discovery result for a GenICam-compatible camera. + + Returned by AravisCamera.enumerate_cameras() and get_device_info(). + Contains identification and connection metadata — no hardware access needed + after discovery. + """ + + serial_number: str + model_name: str + vendor: str + firmware_version: str + connection_type: str # "USB3" for the Cytation 5 BlackFly + + +class AravisCamera: + """BlackFly camera driver using Aravis for GenICam access over USB3 Vision. + + This class wraps all Aravis/GenICam operations for single-frame image + acquisition with software triggering. It mirrors the camera methods that + PLR's CytationBackend calls on PySpin: + + PySpin → AravisCamera + _set_up_camera() → setup(serial_number) + _stop_camera() → stop() + start_acquisition() → start_acquisition() + stop_acquisition() → stop_acquisition() + set_exposure(ms) → set_exposure(ms) + set_gain(val) → set_gain(val) + set_auto_exposure(mode) → set_auto_exposure(mode) + _acquire_image() → trigger(timeout_ms) + + GenICam primer for non-camera-experts: + GenICam is a standard that defines how camera features (exposure, gain, + trigger, etc.) are named and accessed. Every compliant camera publishes + an XML file describing its features as "nodes." Aravis reads this XML + and lets you get/set node values by name (e.g., "ExposureTime", "Gain"). + This is the same mechanism PySpin uses internally — Aravis just provides + a different Python API to access it. + + Buffer management: + Aravis uses a producer-consumer model with pre-allocated buffers. + During setup(), we allocate a small pool of buffers and push them to + the stream. When we trigger a capture, Aravis fills a buffer with image + data. We pop the buffer, copy the data to a numpy array, and push the + buffer back to the pool for reuse. The copy is necessary because the + buffer memory is owned by Aravis and will be reused. + + Usage: + camera = AravisCamera() + await camera.setup("12345678") + await camera.set_exposure(10.0) # 10 ms + image = await camera.trigger() # numpy array, Mono8 + await camera.stop() + """ + + def __init__(self) -> None: + self._camera: Optional[object] = None # Aravis.Camera + self._device: Optional[object] = None # Aravis.Device + self._stream: Optional[object] = None # Aravis.Stream + self._serial_number: Optional[str] = None + self._acquiring: bool = False + self._width: int = 0 + self._height: int = 0 + self._payload_size: int = 0 + + @property + def width(self) -> int: + """Image width in pixels (read-only, from camera default).""" + return self._width + + @property + def height(self) -> int: + """Image height in pixels (read-only, from camera default).""" + return self._height + + async def setup(self, serial_number: str) -> None: + """Connect to camera, configure software trigger, allocate buffers. + + This mirrors CytationBackend._set_up_camera() but uses Aravis instead + of PySpin. The software trigger configuration matches PLR's pattern: + TriggerSelector=FrameStart, TriggerSource=Software, TriggerMode=On. + + Args: + serial_number: Camera serial number (e.g., "12345678"). Use + enumerate_cameras() to discover available cameras. + + Raises: + ImportError: If Aravis/PyGObject is not installed. + RuntimeError: If camera cannot be found or connected. + """ + if not HAS_ARAVIS: + raise ImportError( + "Aravis is not installed. Install it with:\n" + " macOS: brew install aravis && pip install PyGObject\n" + " Linux: sudo apt-get install libaravis-dev gir1.2-aravis-0.8 " + "&& pip install PyGObject\n" + " Windows: pacman -S mingw-w64-x86_64-aravis (in MSYS2)" + ) + + self._serial_number = serial_number + + # Connect to camera by serial number. + # Aravis.Camera.new() expects either the full device ID string or None + # (for first available). We search the device list to find the matching + # device ID for the given serial number. + Aravis.update_device_list() + device_id_to_connect = None + for i in range(Aravis.get_n_devices()): + dev_serial = Aravis.get_device_serial_nbr(i) or "" + dev_id = Aravis.get_device_id(i) or "" + if serial_number in (dev_serial, dev_id) or serial_number in dev_id: + device_id_to_connect = dev_id + break + + if device_id_to_connect is None: + raise RuntimeError( + f"Camera with serial '{serial_number}' not found. " + f"Available devices: {[Aravis.get_device_id(i) for i in range(Aravis.get_n_devices())]}" + ) + + try: + self._camera = Aravis.Camera.new(device_id_to_connect) + except Exception as e: + raise RuntimeError( + f"Failed to connect to camera '{device_id_to_connect}'. " + f"Is the camera in use by another process? Error: {e}" + ) from e + + self._device = self._camera.get_device() + + # Configure software trigger mode. + # GenICam nodes: TriggerSelector, TriggerSource, TriggerMode are + # standard SFNC (Standard Features Naming Convention) names. + self._device.set_string_feature_value("TriggerSelector", "FrameStart") + self._device.set_string_feature_value("TriggerSource", "Software") + self._device.set_string_feature_value("TriggerMode", "On") + + # Read image dimensions and payload size from camera. + self._width = self._camera.get_region()[2] # x, y, width, height + self._height = self._camera.get_region()[3] + self._payload_size = self._camera.get_payload() + + # BlackFly/Flea3 cameras need a delay after trigger mode change. + # Same workaround as CytationBackend (PLR commit 226e6d41). + await asyncio.sleep(1) + + # Create stream and pre-allocate buffer pool. + # Aravis requires buffers to be pushed to the stream before acquisition. + # We allocate a small pool (5 buffers) — for single-frame software + # trigger, we only use one at a time but the pool prevents starvation. + self._stream = self._camera.create_stream(None, None) + for _ in range(_BUFFER_COUNT): + self._stream.push_buffer(Aravis.Buffer.new_allocate(self._payload_size)) + + logger.info( + "AravisCamera: Connected to %s (SN: %s), %dx%d", + self._device.get_string_feature_value("DeviceModelName"), + serial_number, + self._width, + self._height, + ) + + def start_acquisition(self) -> None: + """Begin camera acquisition (no-op if already acquiring). + + Mirrors CytationBackend.start_acquisition(). After this call, the + camera is ready to receive software triggers and produce image buffers. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + if self._acquiring: + return + self._camera.start_acquisition() + self._acquiring = True + + def stop_acquisition(self) -> None: + """End camera acquisition (no-op if not acquiring). + + Mirrors CytationBackend.stop_acquisition(). + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + if not self._acquiring: + return + self._camera.stop_acquisition() + self._acquiring = False + + async def trigger(self, timeout_ms: int = 5000) -> np.ndarray: + """Capture a single frame: start → software trigger → grab → stop. + + This mirrors CytationBackend._acquire_image() but uses Aravis buffer + management instead of PySpin's GetNextImage(). + + The flow: + 1. Start acquisition (if not already active) + 2. Execute TriggerSoftware GenICam command + 3. Pop a filled buffer from the stream (with timeout) + 4. Copy buffer data to numpy array (Mono8 → uint8) + 5. Push buffer back to pool for reuse + 6. Stop acquisition + + Args: + timeout_ms: Maximum time to wait for image buffer, in milliseconds. + Default 5000 (5 seconds). + + Returns: + numpy.ndarray: Image as 2D uint8 array (height × width), Mono8 format. + + Raises: + RuntimeError: If camera not initialized or capture times out. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + + self.start_acquisition() + + try: + # Send software trigger command. + # This is equivalent to PySpin's TriggerSoftware.Execute(). + self._device.execute_command("TriggerSoftware") + + # Pop the filled buffer from the stream. + # timeout_pop_buffer takes microseconds, so convert from ms. + buffer = self._stream.timeout_pop_buffer(timeout_ms * 1000) + if buffer is None: + raise RuntimeError( + f"Camera capture timed out after {timeout_ms}ms. " + "Is the camera connected and trigger mode configured?" + ) + + # Extract image data and copy to numpy array. + # We must copy because the buffer memory is owned by Aravis and + # will be reused when we push the buffer back to the pool. + data = buffer.get_data() + image = np.frombuffer(data, dtype=np.uint8).reshape( + self._height, self._width + ).copy() + + # Return buffer to pool for reuse. + self._stream.push_buffer(buffer) + + return image + finally: + self.stop_acquisition() + + async def stop(self) -> None: + """Release camera and free all Aravis resources. + + Mirrors CytationBackend._stop_camera(). Safe to call at any point — + stops acquisition if active, resets trigger mode, releases camera. + """ + try: + if self._acquiring and self._camera is not None: + self.stop_acquisition() + + if self._device is not None: + try: + self._device.set_string_feature_value("TriggerMode", "Off") + except Exception: + pass # Camera may already be disconnected + finally: + self._camera = None + self._device = None + self._stream = None + self._serial_number = None + self._acquiring = False + self._width = 0 + self._height = 0 + self._payload_size = 0 + + async def set_exposure(self, exposure_ms: float) -> None: + """Set exposure time in milliseconds. + + Disables auto-exposure first, then sets the GenICam ExposureTime node. + PLR's CytationBackend uses milliseconds externally — GenICam uses + microseconds internally. This method handles the conversion. + + Args: + exposure_ms: Exposure time in milliseconds (e.g., 10.0 = 10 ms). + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + # Disable auto-exposure before setting manual value. + self._device.set_string_feature_value("ExposureAuto", "Off") + # GenICam ExposureTime is in microseconds. + exposure_us = exposure_ms * 1000.0 + self._camera.set_exposure_time(exposure_us) + + async def get_exposure(self) -> float: + """Read current exposure time in milliseconds (from hardware, not cached).""" + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + exposure_us = self._camera.get_exposure_time() + return exposure_us / 1000.0 + + async def set_gain(self, gain: float) -> None: + """Set gain value. + + Disables auto-gain first, then sets the GenICam Gain node. + The gain range depends on the camera model (typically 0-30 for BlackFly). + + Args: + gain: Gain value (e.g., 1.0). + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + self._device.set_string_feature_value("GainAuto", "Off") + self._camera.set_gain(gain) + + async def get_gain(self) -> float: + """Read current gain value (from hardware, not cached).""" + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + return self._camera.get_gain() + + async def set_auto_exposure(self, mode: str) -> None: + """Set auto-exposure mode. + + Args: + mode: One of "off", "once", "continuous". Maps to GenICam + ExposureAuto node values: Off, Once, Continuous. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + mode_map = {"off": "Off", "once": "Once", "continuous": "Continuous"} + aravis_mode = mode_map.get(mode.lower()) + if aravis_mode is None: + raise ValueError( + f"Invalid auto-exposure mode '{mode}'. Use 'off', 'once', or 'continuous'." + ) + self._device.set_string_feature_value("ExposureAuto", aravis_mode) + + async def set_pixel_format(self, fmt: Optional[int] = None) -> None: + """Set pixel format. Default is Mono8. + + Must be called before start_acquisition(). The format value is an + Aravis pixel format constant (e.g., Aravis.PIXEL_FORMAT_MONO_8). + + Args: + fmt: Aravis pixel format constant. If None, uses Mono8. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + if fmt is None: + if HAS_ARAVIS: + fmt = Aravis.PIXEL_FORMAT_MONO_8 + else: + return + self._camera.set_pixel_format(fmt) + + def get_device_info(self) -> CameraInfo: + """Read camera identification from GenICam nodes. + + Returns model name, serial number, vendor, and firmware version + without requiring acquisition to be active. + + Returns: + CameraInfo with fields populated from the camera's GenICam XML. + """ + if self._device is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + return CameraInfo( + serial_number=self._device.get_string_feature_value("DeviceSerialNumber"), + model_name=self._device.get_string_feature_value("DeviceModelName"), + vendor=self._device.get_string_feature_value("DeviceVendorName"), + firmware_version=self._device.get_string_feature_value( + "DeviceFirmwareVersion" + ), + connection_type="USB3", + ) + + @staticmethod + def enumerate_cameras() -> list[CameraInfo]: + """List all connected GenICam-compatible cameras. + + Uses Aravis device enumeration — finds cameras across USB3 Vision + and GigE Vision transports (though this driver targets USB3 only). + + Returns: + List of CameraInfo for each detected camera. Empty list if none + found (not an error — matches PLR's graceful enumeration pattern). + + Raises: + ImportError: If Aravis/PyGObject is not installed. + """ + if not HAS_ARAVIS: + raise ImportError( + "Aravis is not installed. Install it with:\n" + " macOS: brew install aravis && pip install PyGObject\n" + " Linux: sudo apt-get install libaravis-dev gir1.2-aravis-0.8 " + "&& pip install PyGObject\n" + " Windows: pacman -S mingw-w64-x86_64-aravis (in MSYS2)" + ) + + Aravis.update_device_list() + n_devices = Aravis.get_n_devices() + cameras: list[CameraInfo] = [] + + for i in range(n_devices): + # Parse info from device ID string without opening the camera. + # Opening the camera here would lock the USB device and prevent + # a subsequent setup() from connecting (GObject doesn't release + # the USB handle reliably on del). + device_id = Aravis.get_device_id(i) + # device_id format varies: "USB3Vision-vendor-model-serial" or similar + serial = Aravis.get_device_serial_nbr(i) or device_id + model = Aravis.get_device_model(i) or "Unknown" + vendor = Aravis.get_device_vendor(i) or "Unknown" + protocol = Aravis.get_device_protocol(i) or "USB3" + + info = CameraInfo( + serial_number=serial, + model_name=model, + vendor=vendor, + firmware_version="", # Not available without opening camera + connection_type=protocol, + ) + cameras.append(info) + + return cameras diff --git a/pylabrobot/agilent/biotek/aravis_simulated.py b/pylabrobot/agilent/biotek/aravis_simulated.py new file mode 100644 index 00000000000..dab190000d5 --- /dev/null +++ b/pylabrobot/agilent/biotek/aravis_simulated.py @@ -0,0 +1,213 @@ +"""Simulated BlackFly camera for testing without hardware. + +Layer: Simulated camera driver (no native dependencies) +Role: Drop-in replacement for AravisCamera that returns synthetic images. +Adjacent layers: + - Above: CytationAravisBackend or test code uses this instead of AravisCamera + - Below: No hardware — generates images from noise + parameters + +This module provides a simulated camera that implements the same public API +as AravisCamera but requires no Aravis library, no GObject introspection, +and no physical camera. It is used for: + - Unit and integration tests (pytest in CI) + - Development without hardware access + - Demonstrating the camera API + +The simulated images have brightness that scales with exposure and gain, +giving a rough visual indication that parameters are being applied. This +is NOT a physics-accurate simulation — it's enough to verify the API works. + +Architecture label: **[Proposed]** — Simulated backend for Aravis driver. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +import numpy as np + +from .aravis_camera import CameraInfo + +logger = logging.getLogger(__name__) + + +class AravisSimulated: + """Simulated BlackFly camera — same API as AravisCamera, no native deps. + + Returns synthetic Mono8 images with brightness proportional to exposure + and gain settings. All parameter operations store and return values + without hardware access. + + Constitution Principle VII (Simulator Parity): This class implements the + same interface as AravisCamera so it can be used as a drop-in replacement + in tests and development environments. + + Usage: + camera = AravisSimulated(width=720, height=540) + await camera.setup("SIM-001") + await camera.set_exposure(10.0) + image = await camera.trigger() # synthetic noise image + await camera.stop() + """ + + def __init__(self, width: int = 720, height: int = 540) -> None: + self._default_width = width + self._default_height = height + self._width: int = 0 + self._height: int = 0 + self._serial_number: Optional[str] = None + self._exposure_ms: float = 10.0 + self._gain: float = 1.0 + self._auto_exposure: str = "off" + self._acquiring: bool = False + self._setup_done: bool = False + + @property + def width(self) -> int: + """Image width in pixels (read-only).""" + return self._width + + @property + def height(self) -> int: + """Image height in pixels (read-only).""" + return self._height + + async def setup(self, serial_number: str) -> None: + """Simulate camera connection. + + Args: + serial_number: Any string — stored for get_device_info(). + """ + self._serial_number = serial_number + self._width = self._default_width + self._height = self._default_height + self._exposure_ms = 10.0 + self._gain = 1.0 + self._auto_exposure = "off" + self._acquiring = False + self._setup_done = True + logger.info( + "AravisSimulated: Connected to simulated camera (SN: %s), %dx%d", + serial_number, + self._width, + self._height, + ) + + def start_acquisition(self) -> None: + """Simulate starting acquisition.""" + if not self._setup_done: + raise RuntimeError("Camera is not initialized. Call setup() first.") + if self._acquiring: + return + self._acquiring = True + + def stop_acquisition(self) -> None: + """Simulate stopping acquisition.""" + if not self._setup_done: + raise RuntimeError("Camera is not initialized. Call setup() first.") + if not self._acquiring: + return + self._acquiring = False + + async def trigger(self, timeout_ms: int = 5000) -> np.ndarray: + """Return a synthetic Mono8 image with brightness scaled by exposure and gain. + + Brightness formula: base = min(255, exposure_ms * 2.5 * (1 + gain / 10)) + Gaussian noise with sigma=10 is added, then clipped to uint8 range. + + Args: + timeout_ms: Ignored in simulation. + + Returns: + numpy.ndarray: Synthetic 2D uint8 array (height × width). + """ + if not self._setup_done: + raise RuntimeError("Camera is not initialized. Call setup() first.") + + self.start_acquisition() + + # Base brightness scales with exposure and gain. + base = min(255.0, self._exposure_ms * 2.5 * (1.0 + self._gain / 10.0)) + noise = np.random.normal(loc=base, scale=10.0, size=(self._height, self._width)) + image = np.clip(noise, 0, 255).astype(np.uint8) + + self.stop_acquisition() + return image + + async def stop(self) -> None: + """Simulate camera release.""" + if self._acquiring: + self.stop_acquisition() + self._serial_number = None + self._width = 0 + self._height = 0 + self._acquiring = False + self._setup_done = False + + async def set_exposure(self, exposure_ms: float) -> None: + """Store exposure value (milliseconds).""" + if not self._setup_done: + raise RuntimeError("Camera is not initialized. Call setup() first.") + self._auto_exposure = "off" + self._exposure_ms = exposure_ms + + async def get_exposure(self) -> float: + """Return stored exposure value (milliseconds).""" + if not self._setup_done: + raise RuntimeError("Camera is not initialized. Call setup() first.") + return self._exposure_ms + + async def set_gain(self, gain: float) -> None: + """Store gain value.""" + if not self._setup_done: + raise RuntimeError("Camera is not initialized. Call setup() first.") + self._gain = gain + + async def get_gain(self) -> float: + """Return stored gain value.""" + if not self._setup_done: + raise RuntimeError("Camera is not initialized. Call setup() first.") + return self._gain + + async def set_auto_exposure(self, mode: str) -> None: + """Store auto-exposure mode ("off", "once", "continuous").""" + if not self._setup_done: + raise RuntimeError("Camera is not initialized. Call setup() first.") + mode_lower = mode.lower() + if mode_lower not in ("off", "once", "continuous"): + raise ValueError( + f"Invalid auto-exposure mode '{mode}'. " + "Use 'off', 'once', or 'continuous'." + ) + self._auto_exposure = mode_lower + + async def set_pixel_format(self, fmt: Optional[int] = None) -> None: + """No-op in simulation — always returns Mono8.""" + if not self._setup_done: + raise RuntimeError("Camera is not initialized. Call setup() first.") + + def get_device_info(self) -> CameraInfo: + """Return simulated camera info.""" + if not self._setup_done: + raise RuntimeError("Camera is not initialized. Call setup() first.") + return CameraInfo( + serial_number=self._serial_number or "SIM-000", + model_name="Simulated BlackFly S", + vendor="Simulated", + firmware_version="1.0.0", + connection_type="USB3", + ) + + @staticmethod + def enumerate_cameras() -> list[CameraInfo]: + """Return a single simulated camera.""" + return [ + CameraInfo( + serial_number="SIM-001", + model_name="Simulated BlackFly S", + vendor="Simulated", + firmware_version="1.0.0", + connection_type="USB3", + ) + ] diff --git a/pylabrobot/agilent/biotek/cytation_aravis.py b/pylabrobot/agilent/biotek/cytation_aravis.py new file mode 100644 index 00000000000..018719296d3 --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation_aravis.py @@ -0,0 +1,687 @@ +"""Cytation 5 backend using Aravis instead of PySpin for camera control. + +Layer: PLR MicroscopyBackend implementation +Role: Drop-in replacement for CytationBackend — same serial protocol for + filter wheel, objective turret, focus motor, LED, and well positioning, + but uses AravisCamera for image acquisition instead of PySpin. +Adjacent layers: + - Above: PLR Microscopy capability calls capture() + - Below: BioTekBackend serial IO (filter/objective/focus/LED) + + AravisCamera (image acquisition via Aravis/GenICam) + +This module exists because CytationBackend (cytation.py) imports PySpin at +module level. Inheriting from CytationBackend would require PySpin to be +installed — defeating the purpose. Instead, CytationAravisBackend inherits +directly from BioTekBackend + MicroscopyBackend and copies the serial protocol +methods from CytationBackend. The camera methods delegate to AravisCamera. + +The serial protocol methods (filter wheel, objectives, focus, LED, well +positioning) are copied from cytation.py (PLR commit 226e6d41). These +methods use self.io (BioTekBackend serial) and do not touch PySpin. + +Architecture label: **[Proposed]** — Aravis as alternative to PySpin for PLR. +""" + +from __future__ import annotations + +import asyncio +import logging +import math +import re +import time +from dataclasses import dataclass, field +from typing import List, Literal, Optional, Tuple, Union + +import numpy as np + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.microscopy.backend import MicroscopyBackend +from pylabrobot.capabilities.microscopy.standard import ( + Exposure, + FocalPosition, + Gain, + Image, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.serializer import SerializableMixin + +from .aravis_camera import AravisCamera +from .biotek import BioTekBackend + +logger = logging.getLogger(__name__) + + +@dataclass +class AravisImagingConfig: + """Imaging configuration for CytationAravisBackend. + + Equivalent to CytationImagingConfig but without PySpin dependencies. + Defines filter wheel positions, objective positions, and camera serial. + """ + + camera_serial_number: Optional[str] = None + filters: Optional[List[Optional[ImagingMode]]] = None + objectives: Optional[List[Optional[Objective]]] = None + max_image_read_attempts: int = 10 + image_read_delay: float = 0.3 + + +class CytationAravisBackend(BioTekBackend, MicroscopyBackend): + """Cytation 5 backend with Aravis camera instead of PySpin. + + This class implements PLR's MicroscopyBackend.capture() by orchestrating: + 1. Well positioning (serial protocol → BioTekBackend) + 2. Filter/objective/focus/LED (serial protocol → copied from CytationBackend) + 3. Exposure/gain (AravisCamera → GenICam nodes) + 4. Image acquisition (AravisCamera → Aravis buffer) + + The serial protocol code is copied from CytationBackend (PLR commit 226e6d41). + Camera operations delegate to AravisCamera. + + Usage: + backend = CytationAravisBackend(camera_serial="12345678") + cytation = Cytation5(backend=backend) + await cytation.setup() + result = await cytation.microscopy.capture(...) + """ + + def __init__( + self, + camera_serial: Optional[str] = None, + timeout: float = 20, + device_id: Optional[str] = None, + imaging_config: Optional[AravisImagingConfig] = None, + ) -> None: + super().__init__( + timeout=timeout, + device_id=device_id, + human_readable_device_name="Agilent BioTek Cytation (Aravis)", + ) + + self._aravis = AravisCamera() + self._camera_serial = camera_serial + self.imaging_config = imaging_config or AravisImagingConfig( + camera_serial_number=camera_serial + ) + + # Imaging state (mirrors CytationBackend) + self._filters: Optional[List[Optional[ImagingMode]]] = ( + self.imaging_config.filters + ) + self._objectives: Optional[List[Optional[Objective]]] = ( + self.imaging_config.objectives + ) + self._exposure: Optional[Exposure] = None + self._focal_height: Optional[FocalPosition] = None + self._gain: Optional[Gain] = None + self._imaging_mode: Optional[ImagingMode] = None + self._row: Optional[int] = None + self._column: Optional[int] = None + self._pos_x: Optional[float] = None + self._pos_y: Optional[float] = None + self._objective: Optional[Objective] = None + self._acquiring = False + + @property + def filters(self) -> List[Optional[ImagingMode]]: + if self._filters is None: + raise RuntimeError("Filters not loaded. Call setup() first.") + return self._filters + + @property + def objectives(self) -> List[Optional[Objective]]: + if self._objectives is None: + raise RuntimeError("Objectives not loaded. Call setup() first.") + return self._objectives + + # ─── Lifecycle ─────────────────────────────────────────────────────── + + async def setup(self, use_cam: bool = True) -> None: + """Set up serial connection and camera. + + Args: + use_cam: If True (default), initialize the Aravis camera. + Set to False for serial-only operations (plate reading). + """ + logger.info("%s setting up", self.__class__.__name__) + await super().setup() + + # Load filter and objective configuration from Cytation via serial + if self._filters is None: + await self._load_filters() + if self._objectives is None: + await self._load_objectives() + + if use_cam: + serial = ( + self._camera_serial + or (self.imaging_config.camera_serial_number if self.imaging_config else None) + ) + if serial is None: + raise RuntimeError( + "No camera serial number provided. Pass camera_serial to constructor " + "or set imaging_config.camera_serial_number." + ) + try: + await self._aravis.setup(serial) + except Exception: + try: + await self.stop() + except Exception: + pass + raise + + async def stop(self) -> None: + """Stop camera and serial connection.""" + logger.info("%s stopping", self.__class__.__name__) + + if self._acquiring: + self.stop_acquisition() + + await self._aravis.stop() + await super().stop() + + self._objectives = None + self._filters = None + self._clear_imaging_state() + + def _clear_imaging_state(self) -> None: + self._exposure = None + self._focal_height = None + self._gain = None + self._imaging_mode = None + self._row = None + self._column = None + self._pos_x = None + self._pos_y = None + + # ─── Filter & Objective Loading (copied from CytationBackend) ──────── + + async def _load_filters(self) -> None: + """Load filter wheel configuration from Cytation via serial. + + Copied from CytationBackend (PLR commit 226e6d41). + Reads 4 filter positions and maps Cytation codes to ImagingMode enums. + """ + self._filters = [] + cytation_code2imaging_mode = { + 1225121: ImagingMode.C377_647, + 1225123: ImagingMode.C400_647, + 1225113: ImagingMode.C469_593, + 1225109: ImagingMode.ACRIDINE_ORANGE, + 1225107: ImagingMode.CFP, + 1225118: ImagingMode.CFP_FRET_V2, + 1225110: ImagingMode.CFP_YFP_FRET, + 1225119: ImagingMode.CFP_YFP_FRET_V2, + 1225112: ImagingMode.CHLOROPHYLL_A, + 1225105: ImagingMode.CY5, + 1225114: ImagingMode.CY5_5, + 1225106: ImagingMode.CY7, + 1225100: ImagingMode.DAPI, + 1225101: ImagingMode.GFP, + 1225116: ImagingMode.GFP_CY5, + 1225122: ImagingMode.OXIDIZED_ROGFP2, + 1225111: ImagingMode.PROPIDIUM_IODIDE, + 1225103: ImagingMode.RFP, + 1225117: ImagingMode.RFP_CY5, + 1225115: ImagingMode.TAG_BFP, + 1225102: ImagingMode.TEXAS_RED, + 1225104: ImagingMode.YFP, + } + for spot in range(1, 5): + configuration = await self.send_command("i", f"q{spot}") + assert configuration is not None + parts = configuration.decode().strip().split(" ") + if len(parts) == 1: + self._filters.append(None) + else: + cytation_code = int(parts[0]) + if cytation_code not in cytation_code2imaging_mode: + self._filters.append(None) + else: + self._filters.append(cytation_code2imaging_mode[cytation_code]) + + async def _load_objectives(self) -> None: + """Load objective turret configuration from Cytation via serial. + + Copied from CytationBackend (PLR commit 226e6d41). + Reads objective positions and maps to Objective enums. + """ + self._objectives = [] + if self.version.startswith("1"): + weird_encoding = { + 0x00: " ", 0x14: ".", 0x15: "/", 0x16: "0", 0x17: "1", + 0x18: "2", 0x19: "3", 0x20: "4", 0x21: "5", 0x22: "6", + 0x23: "7", 0x24: "8", 0x25: "9", 0x33: "A", 0x34: "B", + 0x35: "C", 0x36: "D", 0x37: "E", 0x38: "F", 0x39: "G", + 0x40: "H", 0x41: "I", 0x42: "J", 0x43: "K", 0x44: "L", + 0x45: "M", 0x46: "N", 0x47: "O", 0x48: "P", 0x49: "Q", + 0x50: "R", 0x51: "S", 0x52: "T", 0x53: "U", 0x54: "V", + 0x55: "W", 0x56: "X", 0x57: "Y", 0x58: "Z", + } + part_number2objective = { + "uplsapo 40x2": Objective.O_40X_PL_APO, + "lucplfln 60X": Objective.O_60X_PL_FL, + "uplfln 4x": Objective.O_4X_PL_FL, + "lucplfln 20xph": Objective.O_20X_PL_FL_Phase, + "lucplfln 40xph": Objective.O_40X_PL_FL_Phase, + "u plan": Objective.O_2_5X_PL_ACH_Meiji, + "uplfln 10xph": Objective.O_10X_PL_FL_Phase, + "plapon 1.25x": Objective.O_1_25X_PL_APO, + "uplfln 10x": Objective.O_10X_PL_FL, + "uplfln 60xoi": Objective.O_60X_OIL_PL_FL, + "pln 4x": Objective.O_4X_PL_ACH, + "pln 40x": Objective.O_40X_PL_ACH, + "lucplfln 40x": Objective.O_40X_PL_FL, + "ec-h-plan/2x": Objective.O_2X_PL_ACH_Motic, + "uplfln 100xO2": Objective.O_100X_OIL_PL_FL, + "uplfln 4xph": Objective.O_4X_PL_FL_Phase, + "lucplfln 20X": Objective.O_20X_PL_FL, + "pln 20x": Objective.O_20X_PL_ACH, + "fluar 2.5x/0.12": Objective.O_2_5X_FL_Zeiss, + "uplsapo 100xo": Objective.O_100X_OIL_PL_APO, + "plapon 60xo": Objective.O_60X_OIL_PL_APO, + "uplsapo 20x": Objective.O_20X_PL_APO, + } + for spot in [1, 2]: + configuration = await self.send_command("i", f"o{spot}") + if configuration is None: + raise RuntimeError("Failed to load objective configuration") + middle_part = re.split( + r"\s+", configuration.rstrip(b"\x03").decode("utf-8") + )[1] + if middle_part == "0000": + self._objectives.append(None) + else: + part_number = "".join( + [weird_encoding[x] for x in bytes.fromhex(middle_part)] + ) + self._objectives.append( + part_number2objective.get(part_number.lower()) + ) + elif self.version.startswith("2"): + annulus_part_number2objective = { + 1320520: Objective.O_4X_PL_FL_Phase, + 1320521: Objective.O_20X_PL_FL_Phase, + 1322026: Objective.O_40X_PL_FL_Phase, + } + for spot in range(1, 7): + configuration = await self.send_command("i", f"h{spot + 1}") + assert configuration is not None + if configuration.startswith(b"****"): + self._objectives.append(None) + else: + code = int( + configuration.decode("latin").strip().split(" ")[0] + ) + self._objectives.append( + annulus_part_number2objective.get(code) + ) + self._objective = None + + # ─── Acquisition Control ───────────────────────────────────────────── + + def start_acquisition(self) -> None: + """Start camera acquisition.""" + if self._acquiring: + return + self._aravis.start_acquisition() + self._acquiring = True + + def stop_acquisition(self) -> None: + """Stop camera acquisition.""" + if not self._acquiring: + return + self._aravis.stop_acquisition() + self._acquiring = False + + # ─── Camera Parameters ─────────────────────────────────────────────── + + async def set_exposure(self, exposure: Exposure) -> None: + """Set exposure time (milliseconds) or 'machine-auto'. + + Mirrors CytationBackend.set_exposure(). + """ + if exposure == self._exposure: + return + + if isinstance(exposure, str): + if exposure == "machine-auto": + await self.set_auto_exposure("continuous") + self._exposure = "machine-auto" + return + raise ValueError("exposure must be a number or 'machine-auto'") + + await self._aravis.set_exposure(float(exposure)) + self._exposure = exposure + + async def set_gain(self, gain: Gain) -> None: + """Set gain value or 'machine-auto'. + + Mirrors CytationBackend.set_gain(). + """ + if gain == self._gain: + return + + if gain == "machine-auto": + # Aravis auto-gain via GainAuto node + self._aravis._device.set_string_feature_value("GainAuto", "Continuous") + self._gain = "machine-auto" + return + + await self._aravis.set_gain(float(gain)) + self._gain = gain + + async def set_auto_exposure( + self, auto_exposure: Literal["off", "once", "continuous"] + ) -> None: + """Set auto-exposure mode. Delegates to AravisCamera.""" + await self._aravis.set_auto_exposure(auto_exposure) + + # ─── Image Acquisition ─────────────────────────────────────────────── + + async def _acquire_image(self) -> Image: + """Capture a single frame via AravisCamera. + + Mirrors CytationBackend._acquire_image(). Includes retry logic + matching the original's max_image_read_attempts pattern. + """ + max_attempts = self.imaging_config.max_image_read_attempts + delay = self.imaging_config.image_read_delay + + for attempt in range(max_attempts): + try: + image = await self._aravis.trigger(timeout_ms=5000) + return image + except RuntimeError as e: + if attempt < max_attempts - 1: + logger.warning( + "Image capture attempt %d/%d failed: %s", + attempt + 1, + max_attempts, + e, + ) + await asyncio.sleep(delay) + else: + raise + + raise RuntimeError("Image capture failed after all attempts") + + # ─── Serial Protocol (copied from CytationBackend, PLR commit 226e6d41) ─ + + def _imaging_mode_code(self, mode: ImagingMode) -> int: + """Map ImagingMode to Cytation filter wheel code. + + Brightfield and phase contrast use code 5. + Fluorescence modes use their index in the filter list (1-based). + """ + if mode in (ImagingMode.BRIGHTFIELD, ImagingMode.PHASE_CONTRAST): + return 5 + return self.filters.index(mode) + 1 + + def _objective_code(self, objective: Objective) -> int: + """Map Objective to Cytation turret position (1-based).""" + return self.objectives.index(objective) + 1 + + async def set_imaging_mode( + self, mode: ImagingMode, led_intensity: int = 10 + ) -> None: + """Set filter wheel position and LED. Copied from CytationBackend.""" + if mode == self._imaging_mode: + logger.debug("Imaging mode is already set to %s", mode) + await self.led_on(intensity=led_intensity) + return + + if mode == ImagingMode.COLOR_BRIGHTFIELD: + raise NotImplementedError("Color brightfield imaging not implemented yet") + + await self.led_off() + filter_index = self._imaging_mode_code(mode) + + if self.version.startswith("1"): + if mode == ImagingMode.PHASE_CONTRAST: + raise NotImplementedError( + "Phase contrast not implemented on Cytation1" + ) + elif mode == ImagingMode.BRIGHTFIELD: + await self.send_command("Y", "P0c05") + await self.send_command("Y", "P0f02") + else: + await self.send_command("Y", f"P0c{filter_index:02}") + await self.send_command("Y", "P0f01") + else: + if mode == ImagingMode.PHASE_CONTRAST: + await self.send_command("Y", "P1120") + await self.send_command("Y", "P0d05") + await self.send_command("Y", "P1002") + elif mode == ImagingMode.BRIGHTFIELD: + await self.send_command("Y", "P1101") + await self.send_command("Y", "P0d05") + await self.send_command("Y", "P1002") + else: + await self.send_command("Y", "P1101") + await self.send_command("Y", f"P0d{filter_index:02}") + await self.send_command("Y", "P1001") + + self._imaging_mode = mode + await self.led_on(intensity=led_intensity) + + async def set_objective(self, objective: Objective) -> None: + """Move objective turret. Copied from CytationBackend.""" + if objective == self._objective: + return + + objective_code = self._objective_code(objective) + + if self.version.startswith("1"): + await self.send_command("Y", f"P0d{objective_code:02}", timeout=60) + else: + await self.send_command("Y", f"P0e{objective_code:02}", timeout=60) + + self._objective = objective + self._imaging_mode = None + + async def set_focus(self, focal_position: FocalPosition) -> None: + """Set focus motor position (mm). Copied from CytationBackend.""" + if focal_position == "machine-auto": + raise ValueError( + "focal_position cannot be 'machine-auto'. " + "Use the PLR Imager universal autofocus instead." + ) + + if focal_position == self._focal_height: + return + + slope, intercept = (10.637991436186072, 1.0243013203461762) + focus_integer = int( + focal_position + intercept + slope * focal_position * 1000 + ) + focus_str = str(focus_integer).zfill(5) + + if self._imaging_mode is None: + raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") + imaging_mode_code = self._imaging_mode_code(self._imaging_mode) + await self.send_command("i", f"F{imaging_mode_code}0{focus_str}") + + self._focal_height = focal_position + + async def led_on(self, intensity: int = 10) -> None: + """Turn on LED. Copied from CytationBackend.""" + if not 1 <= intensity <= 10: + raise ValueError("intensity must be between 1 and 10") + intensity_str = str(intensity).zfill(2) + if self._imaging_mode is None: + raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") + imaging_mode_code = self._imaging_mode_code(self._imaging_mode) + await self.send_command("i", f"L0{imaging_mode_code}{intensity_str}") + + async def led_off(self) -> None: + """Turn off LED. Copied from CytationBackend.""" + await self.send_command("i", "L0001") + + async def select(self, row: int, column: int) -> None: + """Move to well position. Copied from CytationBackend.""" + if row == self._row and column == self._column: + return + row_str = str(row).zfill(2) + column_str = str(column).zfill(2) + await self.send_command("Y", f"W6{row_str}{column_str}") + self._row, self._column = row, column + self._pos_x, self._pos_y = None, None + await self.set_position(0, 0) + + async def set_position(self, x: float, y: float) -> None: + """Set precise position within well. Adapted from CytationBackend.""" + if self._imaging_mode is None: + raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") + + if x == self._pos_x and y == self._pos_y: + return + + x_str = str(round(x * 100 * 0.984)).zfill(6) + y_str = str(round(y * 100 * 0.984)).zfill(6) + + if self._row is None or self._column is None: + raise ValueError("Row and column not set. Call select() first.") + row_str = str(self._row).zfill(2) + column_str = str(self._column).zfill(2) + + if self._objective is None: + raise ValueError("Objective not set. Call set_objective() first.") + objective_code = self._objective_code(self._objective) + imaging_mode_code = self._imaging_mode_code(self._imaging_mode) + await self.send_command( + "Y", + f"Z{objective_code}{imaging_mode_code}6{row_str}{column_str}" + f"{y_str}{x_str}", + ) + + self._pos_x, self._pos_y = x, y + + # set_plate() is inherited from BioTekBackend — sends plate geometry + # (well positions, plate dimensions, plate height) to the Cytation via + # serial command "y". Do NOT override — the Cytation needs this to + # position the stage correctly. + + # ─── Vendor Params ────────────────────────────────────────────────── + + @dataclass + class CaptureParams(BackendParams): + """Aravis-specific capture parameters. + + Passed via ``backend_params`` in ``Microscopy.capture()``. + Mirrors CytationBackend.CaptureParams but without PySpin-specific + fields (color_processing_algorithm, pixel_format). + """ + + led_intensity: int = 10 + coverage: Union[Literal["full"], Tuple[int, int]] = (1, 1) + center_position: Optional[Tuple[float, float]] = None + auto_stop_acquisition: bool = True + + # ─── MicroscopyBackend.capture() ───────────────────────────────────── + + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + backend_params: Optional[SerializableMixin] = None, + ) -> ImagingResult: + """Capture image(s) from a well. Implements MicroscopyBackend.capture(). + + Orchestrates the full imaging pipeline: + 1. Set plate geometry + 2. Start camera acquisition + 3. Set objective (turret motor, serial) + 4. Set imaging mode / filter (filter wheel, serial) + 5. Select well (stage motor, serial) + 6. Set exposure and gain (AravisCamera → GenICam) + 7. Set focal height (focus motor, serial) + 8. Trigger and grab image (AravisCamera → Aravis buffer) + 9. Return ImagingResult + + This mirrors CytationBackend.capture() but uses AravisCamera + instead of PySpin for image acquisition. + """ + if not isinstance(backend_params, self.CaptureParams): + backend_params = CytationAravisBackend.CaptureParams() + + led_intensity = backend_params.led_intensity + coverage = backend_params.coverage + center_position = backend_params.center_position + auto_stop_acquisition = backend_params.auto_stop_acquisition + + await self.set_plate(plate) + + if not self._acquiring: + self.start_acquisition() + + try: + await self.set_objective(objective) + await self.set_imaging_mode(mode, led_intensity=led_intensity) + await self.select(row, column) + await self.set_exposure(exposure_time) + await self.set_gain(gain) + await self.set_focus(focal_height) + + if center_position is not None: + await self.set_position(center_position[0], center_position[1]) + + images: List[Image] = [] + + if coverage == (1, 1): + # Single image capture + image = await self._acquire_image() + images.append(image) + else: + # Multi-position tiling (matches CytationBackend pattern) + if self._objective is None: + raise RuntimeError("Objective not set.") + magnification = self._objective.magnification + + # Image field of view by magnification (mm) + fov_map = {4: 3.474, 20: 0.694, 40: 0.347} + fov = fov_map.get(int(magnification), 3.474) + + if coverage == "full": + first_well = plate.get_item(0) + well_w = first_well.get_size_x() + well_h = first_well.get_size_y() + rows_n = math.ceil(well_h / fov) + cols_n = math.ceil(well_w / fov) + else: + rows_n, cols_n = coverage + + cx = center_position[0] if center_position else 0.0 + cy = center_position[1] if center_position else 0.0 + + for yi in range(rows_n): + for xi in range(cols_n): + x_pos = (xi - (cols_n - 1) / 2) * fov + cx + y_pos = -(yi - (rows_n - 1) / 2) * fov + cy + await self.set_position(x=x_pos, y=y_pos) + image = await self._acquire_image() + images.append(image) + + finally: + await self.led_off() + if auto_stop_acquisition: + self.stop_acquisition() + + exposure_ms = await self._aravis.get_exposure() + focal_height_val = float(self._focal_height) if self._focal_height else 0.0 + + return ImagingResult( + images=images, + exposure_time=exposure_ms, + focal_height=focal_height_val, + ) diff --git a/pylabrobot/agilent/biotek/test_aravis_connection.ipynb b/pylabrobot/agilent/biotek/test_aravis_connection.ipynb new file mode 100644 index 00000000000..0345eda18e6 --- /dev/null +++ b/pylabrobot/agilent/biotek/test_aravis_connection.ipynb @@ -0,0 +1,539 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Aravis BlackFly Camera Driver — Hardware Test\n", + "\n", + "Tests the Aravis driver on real Cytation hardware. Two parts:\n", + "\n", + "- **Part 1** (Steps 1–9b): Camera only via raw Aravis. No serial, no motors, safe.\n", + "- **Part 2** (Steps 10–13): Full Cytation integration via `CytationAravisBackend`.\n", + "\n", + "## Prerequisites\n", + "\n", + "```bash\n", + "# 1. Aravis system library\n", + "brew install aravis # macOS\n", + "\n", + "# 2. Python dependencies\n", + "cd ~/vs\\ code/pylabrobot\n", + "poetry install\n", + "\n", + "# 3. Register Jupyter kernel\n", + "poetry run python -m ipykernel install --user --name pylabrobot-workspace --display-name \"PyLabRobot (Aravis)\"\n", + "```\n", + "\n", + "Then select **PyLabRobot (Aravis)** as kernel in VS Code (top-right).\n", + "\n", + "---\n", + "\n", + "# Part 1: Camera Only (Aravis Direct)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 0: Verify Kernel and Dependencies" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "\n", + "print(f\"Python: {sys.executable}\")\n", + "print(f\"Version: {sys.version}\\n\")\n", + "\n", + "deps = {\"numpy\": \"numpy\", \"matplotlib\": \"matplotlib\", \"pylibftdi\": \"pylibftdi\", \"pylabrobot\": \"pylabrobot\"}\n", + "all_ok = True\n", + "for name, module in deps.items():\n", + " try:\n", + " __import__(module)\n", + " print(f\" {name}: OK\")\n", + " except ImportError:\n", + " print(f\" {name}: MISSING\")\n", + " all_ok = False\n", + "\n", + "try:\n", + " import gi\n", + " gi.require_version(\"Aravis\", \"0.8\")\n", + " from gi.repository import Aravis\n", + " print(f\" aravis: OK\")\n", + "except (ImportError, ValueError):\n", + " print(f\" aravis: MISSING — brew install aravis && pip install PyGObject\")\n", + " all_ok = False\n", + "\n", + "print(f\"\\n{'All checks passed!' if all_ok else 'Fix missing dependencies before continuing.'}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1: Verify Aravis Installation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import subprocess\n", + "import gi\n", + "\n", + "gi.require_version(\"Aravis\", \"0.8\")\n", + "from gi.repository import Aravis\n", + "\n", + "\n", + "def _aravis_library_version():\n", + " \"\"\"Aravis C library semver; not exposed on the gi.repository.Aravis module.\"\"\"\n", + " for pkg in (\"aravis-0.8\", \"aravis\"):\n", + " try:\n", + " out = subprocess.check_output(\n", + " [\"pkg-config\", \"--modversion\", pkg],\n", + " stderr=subprocess.DEVNULL,\n", + " text=True,\n", + " )\n", + " return out.strip()\n", + " except (subprocess.CalledProcessError, FileNotFoundError):\n", + " continue\n", + " return None\n", + "\n", + "\n", + "print(\"Aravis loaded successfully\")\n", + "lib_ver = _aravis_library_version()\n", + "if lib_ver:\n", + " print(f\"Aravis library version: {lib_ver}\")\n", + "else:\n", + " print(\"Aravis library version: (unknown — pkg-config not available)\")\n", + "print(f\"GObject introspection API: Aravis {Aravis._version}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2: Discover Cameras" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "Aravis.update_device_list()\n", + "n_devices = Aravis.get_n_devices()\n", + "print(f\"Found {n_devices} camera(s):\\n\")\n", + "\n", + "for i in range(n_devices):\n", + " device_id = Aravis.get_device_id(i)\n", + " print(f\" [{i}] {device_id}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3: Connect to the BlackFly\n", + "\n", + "Set `SERIAL` to your camera's serial number from Step 2, or `None` for first camera." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "SERIAL = None # e.g., \"17525520\"\n", + "\n", + "camera = Aravis.Camera.new(SERIAL)\n", + "device = camera.get_device()\n", + "\n", + "print(f\"Connected!\")\n", + "print(f\" Model: {device.get_string_feature_value('DeviceModelName')}\")\n", + "print(f\" Serial: {device.get_string_feature_value('DeviceSerialNumber')}\")\n", + "print(f\" Vendor: {device.get_string_feature_value('DeviceVendorName')}\")\n", + "print(f\" Firmware: {device.get_string_feature_value('DeviceFirmwareVersion')}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4: Read Camera Properties" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "x, y, width, height = camera.get_region()\n", + "payload = camera.get_payload()\n", + "\n", + "print(f\"Image region: {width} x {height} (offset: {x}, {y})\")\n", + "print(f\"Payload size: {payload} bytes\")\n", + "print(f\"Pixel format: {camera.get_pixel_format_as_string()}\")\n", + "print(f\"\")\n", + "print(f\"Exposure range: {camera.get_exposure_time_bounds()} \\u00b5s\")\n", + "print(f\"Current exposure: {camera.get_exposure_time()} \\u00b5s ({camera.get_exposure_time()/1000:.1f} ms)\")\n", + "print(f\"\")\n", + "print(f\"Gain range: {camera.get_gain_bounds()}\")\n", + "print(f\"Current gain: {camera.get_gain()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5: Configure Software Trigger" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "device.set_string_feature_value(\"TriggerSelector\", \"FrameStart\")\n", + "device.set_string_feature_value(\"TriggerSource\", \"Software\")\n", + "device.set_string_feature_value(\"TriggerMode\", \"On\")\n", + "\n", + "print(\"Software trigger configured:\")\n", + "print(f\" TriggerSelector: {device.get_string_feature_value('TriggerSelector')}\")\n", + "print(f\" TriggerSource: {device.get_string_feature_value('TriggerSource')}\")\n", + "print(f\" TriggerMode: {device.get_string_feature_value('TriggerMode')}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 6: Set Exposure and Gain" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "EXPOSURE_MS = 10.0\n", + "GAIN = 1.0\n", + "\n", + "device.set_string_feature_value(\"ExposureAuto\", \"Off\")\n", + "camera.set_exposure_time(EXPOSURE_MS * 1000)\n", + "print(f\"Exposure set to {camera.get_exposure_time()} \\u00b5s ({camera.get_exposure_time()/1000:.1f} ms)\")\n", + "\n", + "device.set_string_feature_value(\"GainAuto\", \"Off\")\n", + "camera.set_gain(GAIN)\n", + "print(f\"Gain set to {camera.get_gain()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 7: Capture a Single Frame\n", + "\n", + "If this cell works, **Aravis can control your BlackFly**." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "import numpy as np\n", + "\n", + "# BlackFly needs a delay after trigger mode change (same as Rick's CytationBackend)\n", + "time.sleep(1)\n", + "\n", + "# Allocate stream and buffers\n", + "stream = camera.create_stream(None, None)\n", + "payload = camera.get_payload()\n", + "for _ in range(5):\n", + " stream.push_buffer(Aravis.Buffer.new_allocate(payload))\n", + "\n", + "# Start acquisition, software trigger, grab buffer\n", + "camera.start_acquisition()\n", + "device.execute_command(\"TriggerSoftware\")\n", + "buffer = stream.timeout_pop_buffer(10_000_000)\n", + "\n", + "if buffer is None:\n", + " print(\"ERROR: Capture timed out!\")\n", + " print(f\" Pixel format: {camera.get_pixel_format_as_string()}\")\n", + " print(f\" Payload: {payload}, Region: {camera.get_region()}\")\n", + "else:\n", + " data = buffer.get_data()\n", + " _, _, width, height = camera.get_region()\n", + " image = np.frombuffer(data, dtype=np.uint8).reshape(height, width).copy()\n", + " stream.push_buffer(buffer)\n", + " print(f\"Captured! Shape: {image.shape}, dtype: {image.dtype}\")\n", + " print(f\" Min: {image.min()}, Max: {image.max()}, Mean: {image.mean():.1f}\")\n", + "\n", + "camera.stop_acquisition()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 8: Display the Image" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))\n", + "ax1.imshow(image, cmap='gray', vmin=0, vmax=255)\n", + "ax1.set_title(f'BlackFly via Aravis \\u2014 {EXPOSURE_MS}ms, gain={GAIN}')\n", + "ax2.hist(image.ravel(), bins=256, range=(0, 255), color='gray', alpha=0.7)\n", + "ax2.axvline(image.mean(), color='red', linestyle='--', label=f'mean={image.mean():.0f}')\n", + "ax2.set_title('Pixel intensity histogram')\n", + "ax2.legend()\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 9b: Disconnect Raw Camera\n", + "\n", + "**Run this before Part 2.** Releases the camera so `AravisCamera`/`CytationAravisBackend` can reconnect." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import gc, time\n", + "\n", + "try:\n", + " camera.stop_acquisition()\n", + "except Exception:\n", + " pass\n", + "try:\n", + " device.set_string_feature_value(\"TriggerMode\", \"Off\")\n", + "except Exception:\n", + " pass\n", + "\n", + "stream = None\n", + "device = None\n", + "camera = None\n", + "\n", + "gc.collect()\n", + "gc.collect()\n", + "time.sleep(2)\n", + "\n", + "Aravis.update_device_list()\n", + "n = Aravis.get_n_devices()\n", + "print(f\"Camera released. {n} device(s) available.\" if n > 0 else \"WARNING: Camera not released. Restart kernel.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "# Part 2: Cytation Integration (PLR Backend)\n", + "\n", + "Uses `CytationAravisBackend` — Aravis for the camera, PLR's `BioTekBackend` for the Cytation serial protocol (filter wheel, objectives, focus, LED, stage).\n", + "\n", + "**Requires**: Cytation powered on + BlackFly connected via USB." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 10: Setup CytationAravisBackend\n", + "\n", + "Equivalent of Rick's `CytationBackend()` setup — no Spinnaker env var needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "from pylabrobot.capabilities.microscopy.standard import ImagingMode, Objective\n", + "from pylabrobot.agilent.biotek.cytation_aravis import CytationAravisBackend\n", + "\n", + "CAMERA_SERIAL = \"010B6B10\" # from Step 2\n", + "\n", + "backend = CytationAravisBackend(camera_serial=CAMERA_SERIAL)\n", + "await backend.setup(use_cam=True)\n", + "\n", + "print(\"CytationAravisBackend ready\")\n", + "print(f\" Firmware: {backend.version}\")\n", + "print(f\" Objectives: {backend.objectives}\")\n", + "print(f\" Filters: {backend.filters}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 11: Open Door, Load Plate, Close Door" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await backend.open(slow=False)\n", + "print(\"Door open \\u2014 insert plate, then run next cell\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await backend.close(slow=True)\n", + "print(\"Door closed\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 12: Single Capture\n", + "\n", + "Adjust `objective`, `focal_height`, `exposure_time`, `gain` for your plate and objectives." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb\n", + "plate = CellVis_24_wellplate_3600uL_Fb(name=\"plate\")\n", + "\n", + "res = await backend.capture(\n", + " row=2,\n", + " column=3,\n", + " mode=ImagingMode.BRIGHTFIELD,\n", + " objective=Objective.O_2_5X_FL_Zeiss, # your objectives: O_4X_PL_FL, O_2_5X_FL_Zeiss\n", + " focal_height=3,\n", + " exposure_time=5,\n", + " gain=16,\n", + " plate=plate,\n", + " led_intensity=5,\n", + ")\n", + "\n", + "plt.figure(figsize=(8, 6))\n", + "plt.imshow(res.images[0], cmap=\"gray\", vmin=0, vmax=255)\n", + "plt.title(f\"Brightfield 2.5x \\u2014 {res.exposure_time:.1f} ms, focal={res.focal_height:.3f} mm\")\n", + "plt.colorbar(label=\"pixel value\")\n", + "plt.show()\n", + "print(f\"Shape: {res.images[0].shape}, Mean: {res.images[0].mean():.1f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 13: Multi-Image Coverage (Tiling)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "num_rows = 4\n", + "num_cols = 3\n", + "\n", + "res = await backend.capture(\n", + " row=2,\n", + " column=3,\n", + " mode=ImagingMode.BRIGHTFIELD,\n", + " objective=Objective.O_2_5X_FL_Zeiss,\n", + " focal_height=3,\n", + " exposure_time=5,\n", + " gain=16,\n", + " plate=plate,\n", + " led_intensity=5,\n", + " coverage=(num_rows, num_cols),\n", + " center_position=(0, 0),\n", + ")\n", + "\n", + "print(f\"Captured {len(res.images)} images\")\n", + "\n", + "fig = plt.figure(figsize=(12, 8))\n", + "for row in range(num_rows):\n", + " for col in range(num_cols):\n", + " plt.subplot(num_rows, num_cols, row * num_cols + col + 1)\n", + " plt.imshow(res.images[row * num_cols + col], cmap=\"gray\", vmin=0, vmax=255)\n", + " plt.axis(\"off\")\n", + "plt.suptitle(f\"Brightfield 2.5x Zeiss \\u2014 {num_rows}x{num_cols} coverage\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Cleanup\n", + "\n", + "Always run this when done." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await backend.stop()\n", + "print(\"Backend stopped \\u2014 camera and serial released\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "PyLabRobot (Aravis)", + "language": "python", + "name": "pylabrobot-workspace" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file From f5c377131332712c05c8344dc476d153ac323700 Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Fri, 3 Apr 2026 13:18:38 +0200 Subject: [PATCH 2/6] Remove README and test notebook from aravis PR Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/agilent/biotek/ARAVIS_README.md | 142 ----- .../biotek/test_aravis_connection.ipynb | 539 ------------------ 2 files changed, 681 deletions(-) delete mode 100644 pylabrobot/agilent/biotek/ARAVIS_README.md delete mode 100644 pylabrobot/agilent/biotek/test_aravis_connection.ipynb diff --git a/pylabrobot/agilent/biotek/ARAVIS_README.md b/pylabrobot/agilent/biotek/ARAVIS_README.md deleted file mode 100644 index a97399660bc..00000000000 --- a/pylabrobot/agilent/biotek/ARAVIS_README.md +++ /dev/null @@ -1,142 +0,0 @@ -# Aravis Camera Driver for PLR's Cytation - -**Status**: Proof-of-concept, tested on real hardware (Cytation 1 + BlackFly BFLY-U3-13S2M) - -Replaces PySpin with [Aravis](https://github.com/AravisProject/aravis) for controlling the BlackFly camera inside the Cytation. No Spinnaker SDK needed. No Python version cap. - -## Why - -PLR's `CytationBackend` uses PySpin (FLIR Spinnaker SDK) for camera control. PySpin only has pre-built wheels up to Python 3.10, blocking PLR from using newer Python versions. Aravis is an open-source alternative that talks to the camera directly via GenICam/USB3 Vision — no vendor SDK at all. - -## What's Here - -``` -aravis_camera.py # Standalone camera driver (Aravis/GenICam) -aravis_simulated.py # Simulated camera for testing without hardware -cytation_aravis.py # CytationAravisBackend — drop-in for CytationBackend -``` - -### aravis_camera.py - -Standalone camera driver. No PLR dependencies (just numpy). Handles: -- Camera discovery by serial number -- Software trigger (single-frame capture) -- Exposure/gain control via GenICam nodes -- Buffer management (pre-allocated pool) -- Clean connect/disconnect - -Can be used independently or composed into `CytationAravisBackend`. - -### aravis_simulated.py - -Same API as `AravisCamera` but returns synthetic numpy arrays. No Aravis, no GObject, no hardware needed. For testing and CI. - -### cytation_aravis.py - -`CytationAravisBackend(BioTekBackend, MicroscopyBackend)` — uses `AravisCamera` for the camera and PLR's `BioTekBackend` for the Cytation serial protocol (filter wheel, objective turret, focus motor, LED, stage positioning). Drop-in replacement for `CytationBackend`. - -## Installation - -### 1. Aravis system library - -```bash -# macOS -brew install aravis - -# Linux -sudo apt-get install libaravis-dev gir1.2-aravis-0.8 -``` - -### 2. Python dependencies - -```bash -pip install PyGObject numpy -``` - -### 3. Drop files into PLR - -Copy the three `.py` files to `pylabrobot/agilent/biotek/` in your PLR installation. - -## Usage - -### Standalone (camera only, no Cytation serial) - -```python -from aravis_camera import AravisCamera - -camera = AravisCamera() - -# Discover -cameras = AravisCamera.enumerate_cameras() -print(cameras) # [CameraInfo(serial='...', model='Blackfly ...', ...)] - -# Connect, capture, disconnect -await camera.setup(cameras[0].serial_number) -await camera.set_exposure(10.0) # ms -await camera.set_gain(1.0) -image = await camera.trigger() # numpy array (height, width), uint8 -print(image.shape) # (964, 1288) -await camera.stop() -``` - -### With PLR Cytation backend - -```python -from pylabrobot.agilent.biotek.cytation_aravis import CytationAravisBackend -from pylabrobot.capabilities.microscopy.standard import ImagingMode, Objective - -backend = CytationAravisBackend(camera_serial="your_serial_here") -await backend.setup(use_cam=True) - -# Same capture API as CytationBackend — serial protocol + camera -res = await backend.capture( - row=1, column=1, - mode=ImagingMode.BRIGHTFIELD, - objective=Objective.O_4X_PL_FL, - focal_height=3.0, - exposure_time=5, - gain=16, - plate=your_plate, - led_intensity=5, -) - -# res.images[0] is a numpy array -await backend.stop() -``` - -## What It Replaces - -``` -BEFORE: AFTER: -CytationBackend CytationAravisBackend - ├─ serial protocol (BioTek) ├─ serial protocol (same) - └─ PySpin → Spinnaker SDK └─ AravisCamera → Aravis - (proprietary, Python ≤3.10) (open source, any Python) -``` - -Only the camera layer changes. The Cytation serial protocol (filter wheel, objectives, focus, LED, stage) is identical. - -## Tested On - -- Cytation 1 (firmware 1.02) -- BlackFly BFLY-U3-13S2M (USB3 Vision) -- Aravis 0.8.35 -- macOS, Python 3.12 -- Camera acquisition, exposure/gain control, LED, stage movement, focus motor — all working -- Gen5 (BioTek software) still works after Aravis testing — no damage to instrument state - -## Known Gotchas - -1. **1-second delay after trigger mode change** — BlackFly cameras need `asyncio.sleep(1)` after setting TriggerMode=On. Already handled in `AravisCamera.setup()`. - -2. **Don't open camera during enumeration** — `Aravis.Camera.new()` locks the USB device. `enumerate_cameras()` uses lightweight descriptor reads instead. - -3. **Camera serial has two formats** — Aravis device list returns hex (e.g., `010B6B10`), GenICam returns decimal (`17525520`). `setup()` handles both. - -## PLR Branch - -Built against PLR's `capability-architecture` branch (commit 226e6d41). Import paths differ from released PLR (`main`). See the analysis doc for details. - ---- - -*Built with [Claude Code](https://claude.ai/claude-code) by Vincent de Boer* diff --git a/pylabrobot/agilent/biotek/test_aravis_connection.ipynb b/pylabrobot/agilent/biotek/test_aravis_connection.ipynb deleted file mode 100644 index 0345eda18e6..00000000000 --- a/pylabrobot/agilent/biotek/test_aravis_connection.ipynb +++ /dev/null @@ -1,539 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Aravis BlackFly Camera Driver — Hardware Test\n", - "\n", - "Tests the Aravis driver on real Cytation hardware. Two parts:\n", - "\n", - "- **Part 1** (Steps 1–9b): Camera only via raw Aravis. No serial, no motors, safe.\n", - "- **Part 2** (Steps 10–13): Full Cytation integration via `CytationAravisBackend`.\n", - "\n", - "## Prerequisites\n", - "\n", - "```bash\n", - "# 1. Aravis system library\n", - "brew install aravis # macOS\n", - "\n", - "# 2. Python dependencies\n", - "cd ~/vs\\ code/pylabrobot\n", - "poetry install\n", - "\n", - "# 3. Register Jupyter kernel\n", - "poetry run python -m ipykernel install --user --name pylabrobot-workspace --display-name \"PyLabRobot (Aravis)\"\n", - "```\n", - "\n", - "Then select **PyLabRobot (Aravis)** as kernel in VS Code (top-right).\n", - "\n", - "---\n", - "\n", - "# Part 1: Camera Only (Aravis Direct)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 0: Verify Kernel and Dependencies" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import sys\n", - "\n", - "print(f\"Python: {sys.executable}\")\n", - "print(f\"Version: {sys.version}\\n\")\n", - "\n", - "deps = {\"numpy\": \"numpy\", \"matplotlib\": \"matplotlib\", \"pylibftdi\": \"pylibftdi\", \"pylabrobot\": \"pylabrobot\"}\n", - "all_ok = True\n", - "for name, module in deps.items():\n", - " try:\n", - " __import__(module)\n", - " print(f\" {name}: OK\")\n", - " except ImportError:\n", - " print(f\" {name}: MISSING\")\n", - " all_ok = False\n", - "\n", - "try:\n", - " import gi\n", - " gi.require_version(\"Aravis\", \"0.8\")\n", - " from gi.repository import Aravis\n", - " print(f\" aravis: OK\")\n", - "except (ImportError, ValueError):\n", - " print(f\" aravis: MISSING — brew install aravis && pip install PyGObject\")\n", - " all_ok = False\n", - "\n", - "print(f\"\\n{'All checks passed!' if all_ok else 'Fix missing dependencies before continuing.'}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 1: Verify Aravis Installation" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import subprocess\n", - "import gi\n", - "\n", - "gi.require_version(\"Aravis\", \"0.8\")\n", - "from gi.repository import Aravis\n", - "\n", - "\n", - "def _aravis_library_version():\n", - " \"\"\"Aravis C library semver; not exposed on the gi.repository.Aravis module.\"\"\"\n", - " for pkg in (\"aravis-0.8\", \"aravis\"):\n", - " try:\n", - " out = subprocess.check_output(\n", - " [\"pkg-config\", \"--modversion\", pkg],\n", - " stderr=subprocess.DEVNULL,\n", - " text=True,\n", - " )\n", - " return out.strip()\n", - " except (subprocess.CalledProcessError, FileNotFoundError):\n", - " continue\n", - " return None\n", - "\n", - "\n", - "print(\"Aravis loaded successfully\")\n", - "lib_ver = _aravis_library_version()\n", - "if lib_ver:\n", - " print(f\"Aravis library version: {lib_ver}\")\n", - "else:\n", - " print(\"Aravis library version: (unknown — pkg-config not available)\")\n", - "print(f\"GObject introspection API: Aravis {Aravis._version}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 2: Discover Cameras" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "Aravis.update_device_list()\n", - "n_devices = Aravis.get_n_devices()\n", - "print(f\"Found {n_devices} camera(s):\\n\")\n", - "\n", - "for i in range(n_devices):\n", - " device_id = Aravis.get_device_id(i)\n", - " print(f\" [{i}] {device_id}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 3: Connect to the BlackFly\n", - "\n", - "Set `SERIAL` to your camera's serial number from Step 2, or `None` for first camera." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "SERIAL = None # e.g., \"17525520\"\n", - "\n", - "camera = Aravis.Camera.new(SERIAL)\n", - "device = camera.get_device()\n", - "\n", - "print(f\"Connected!\")\n", - "print(f\" Model: {device.get_string_feature_value('DeviceModelName')}\")\n", - "print(f\" Serial: {device.get_string_feature_value('DeviceSerialNumber')}\")\n", - "print(f\" Vendor: {device.get_string_feature_value('DeviceVendorName')}\")\n", - "print(f\" Firmware: {device.get_string_feature_value('DeviceFirmwareVersion')}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 4: Read Camera Properties" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "x, y, width, height = camera.get_region()\n", - "payload = camera.get_payload()\n", - "\n", - "print(f\"Image region: {width} x {height} (offset: {x}, {y})\")\n", - "print(f\"Payload size: {payload} bytes\")\n", - "print(f\"Pixel format: {camera.get_pixel_format_as_string()}\")\n", - "print(f\"\")\n", - "print(f\"Exposure range: {camera.get_exposure_time_bounds()} \\u00b5s\")\n", - "print(f\"Current exposure: {camera.get_exposure_time()} \\u00b5s ({camera.get_exposure_time()/1000:.1f} ms)\")\n", - "print(f\"\")\n", - "print(f\"Gain range: {camera.get_gain_bounds()}\")\n", - "print(f\"Current gain: {camera.get_gain()}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 5: Configure Software Trigger" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "device.set_string_feature_value(\"TriggerSelector\", \"FrameStart\")\n", - "device.set_string_feature_value(\"TriggerSource\", \"Software\")\n", - "device.set_string_feature_value(\"TriggerMode\", \"On\")\n", - "\n", - "print(\"Software trigger configured:\")\n", - "print(f\" TriggerSelector: {device.get_string_feature_value('TriggerSelector')}\")\n", - "print(f\" TriggerSource: {device.get_string_feature_value('TriggerSource')}\")\n", - "print(f\" TriggerMode: {device.get_string_feature_value('TriggerMode')}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 6: Set Exposure and Gain" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "EXPOSURE_MS = 10.0\n", - "GAIN = 1.0\n", - "\n", - "device.set_string_feature_value(\"ExposureAuto\", \"Off\")\n", - "camera.set_exposure_time(EXPOSURE_MS * 1000)\n", - "print(f\"Exposure set to {camera.get_exposure_time()} \\u00b5s ({camera.get_exposure_time()/1000:.1f} ms)\")\n", - "\n", - "device.set_string_feature_value(\"GainAuto\", \"Off\")\n", - "camera.set_gain(GAIN)\n", - "print(f\"Gain set to {camera.get_gain()}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 7: Capture a Single Frame\n", - "\n", - "If this cell works, **Aravis can control your BlackFly**." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import time\n", - "import numpy as np\n", - "\n", - "# BlackFly needs a delay after trigger mode change (same as Rick's CytationBackend)\n", - "time.sleep(1)\n", - "\n", - "# Allocate stream and buffers\n", - "stream = camera.create_stream(None, None)\n", - "payload = camera.get_payload()\n", - "for _ in range(5):\n", - " stream.push_buffer(Aravis.Buffer.new_allocate(payload))\n", - "\n", - "# Start acquisition, software trigger, grab buffer\n", - "camera.start_acquisition()\n", - "device.execute_command(\"TriggerSoftware\")\n", - "buffer = stream.timeout_pop_buffer(10_000_000)\n", - "\n", - "if buffer is None:\n", - " print(\"ERROR: Capture timed out!\")\n", - " print(f\" Pixel format: {camera.get_pixel_format_as_string()}\")\n", - " print(f\" Payload: {payload}, Region: {camera.get_region()}\")\n", - "else:\n", - " data = buffer.get_data()\n", - " _, _, width, height = camera.get_region()\n", - " image = np.frombuffer(data, dtype=np.uint8).reshape(height, width).copy()\n", - " stream.push_buffer(buffer)\n", - " print(f\"Captured! Shape: {image.shape}, dtype: {image.dtype}\")\n", - " print(f\" Min: {image.min()}, Max: {image.max()}, Mean: {image.mean():.1f}\")\n", - "\n", - "camera.stop_acquisition()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 8: Display the Image" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "\n", - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))\n", - "ax1.imshow(image, cmap='gray', vmin=0, vmax=255)\n", - "ax1.set_title(f'BlackFly via Aravis \\u2014 {EXPOSURE_MS}ms, gain={GAIN}')\n", - "ax2.hist(image.ravel(), bins=256, range=(0, 255), color='gray', alpha=0.7)\n", - "ax2.axvline(image.mean(), color='red', linestyle='--', label=f'mean={image.mean():.0f}')\n", - "ax2.set_title('Pixel intensity histogram')\n", - "ax2.legend()\n", - "plt.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 9b: Disconnect Raw Camera\n", - "\n", - "**Run this before Part 2.** Releases the camera so `AravisCamera`/`CytationAravisBackend` can reconnect." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import gc, time\n", - "\n", - "try:\n", - " camera.stop_acquisition()\n", - "except Exception:\n", - " pass\n", - "try:\n", - " device.set_string_feature_value(\"TriggerMode\", \"Off\")\n", - "except Exception:\n", - " pass\n", - "\n", - "stream = None\n", - "device = None\n", - "camera = None\n", - "\n", - "gc.collect()\n", - "gc.collect()\n", - "time.sleep(2)\n", - "\n", - "Aravis.update_device_list()\n", - "n = Aravis.get_n_devices()\n", - "print(f\"Camera released. {n} device(s) available.\" if n > 0 else \"WARNING: Camera not released. Restart kernel.\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "# Part 2: Cytation Integration (PLR Backend)\n", - "\n", - "Uses `CytationAravisBackend` — Aravis for the camera, PLR's `BioTekBackend` for the Cytation serial protocol (filter wheel, objectives, focus, LED, stage).\n", - "\n", - "**Requires**: Cytation powered on + BlackFly connected via USB." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 10: Setup CytationAravisBackend\n", - "\n", - "Equivalent of Rick's `CytationBackend()` setup — no Spinnaker env var needed." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "from pylabrobot.capabilities.microscopy.standard import ImagingMode, Objective\n", - "from pylabrobot.agilent.biotek.cytation_aravis import CytationAravisBackend\n", - "\n", - "CAMERA_SERIAL = \"010B6B10\" # from Step 2\n", - "\n", - "backend = CytationAravisBackend(camera_serial=CAMERA_SERIAL)\n", - "await backend.setup(use_cam=True)\n", - "\n", - "print(\"CytationAravisBackend ready\")\n", - "print(f\" Firmware: {backend.version}\")\n", - "print(f\" Objectives: {backend.objectives}\")\n", - "print(f\" Filters: {backend.filters}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 11: Open Door, Load Plate, Close Door" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.open(slow=False)\n", - "print(\"Door open \\u2014 insert plate, then run next cell\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.close(slow=True)\n", - "print(\"Door closed\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 12: Single Capture\n", - "\n", - "Adjust `objective`, `focal_height`, `exposure_time`, `gain` for your plate and objectives." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb\n", - "plate = CellVis_24_wellplate_3600uL_Fb(name=\"plate\")\n", - "\n", - "res = await backend.capture(\n", - " row=2,\n", - " column=3,\n", - " mode=ImagingMode.BRIGHTFIELD,\n", - " objective=Objective.O_2_5X_FL_Zeiss, # your objectives: O_4X_PL_FL, O_2_5X_FL_Zeiss\n", - " focal_height=3,\n", - " exposure_time=5,\n", - " gain=16,\n", - " plate=plate,\n", - " led_intensity=5,\n", - ")\n", - "\n", - "plt.figure(figsize=(8, 6))\n", - "plt.imshow(res.images[0], cmap=\"gray\", vmin=0, vmax=255)\n", - "plt.title(f\"Brightfield 2.5x \\u2014 {res.exposure_time:.1f} ms, focal={res.focal_height:.3f} mm\")\n", - "plt.colorbar(label=\"pixel value\")\n", - "plt.show()\n", - "print(f\"Shape: {res.images[0].shape}, Mean: {res.images[0].mean():.1f}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Step 13: Multi-Image Coverage (Tiling)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "num_rows = 4\n", - "num_cols = 3\n", - "\n", - "res = await backend.capture(\n", - " row=2,\n", - " column=3,\n", - " mode=ImagingMode.BRIGHTFIELD,\n", - " objective=Objective.O_2_5X_FL_Zeiss,\n", - " focal_height=3,\n", - " exposure_time=5,\n", - " gain=16,\n", - " plate=plate,\n", - " led_intensity=5,\n", - " coverage=(num_rows, num_cols),\n", - " center_position=(0, 0),\n", - ")\n", - "\n", - "print(f\"Captured {len(res.images)} images\")\n", - "\n", - "fig = plt.figure(figsize=(12, 8))\n", - "for row in range(num_rows):\n", - " for col in range(num_cols):\n", - " plt.subplot(num_rows, num_cols, row * num_cols + col + 1)\n", - " plt.imshow(res.images[row * num_cols + col], cmap=\"gray\", vmin=0, vmax=255)\n", - " plt.axis(\"off\")\n", - "plt.suptitle(f\"Brightfield 2.5x Zeiss \\u2014 {num_rows}x{num_cols} coverage\")\n", - "plt.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Cleanup\n", - "\n", - "Always run this when done." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.stop()\n", - "print(\"Backend stopped \\u2014 camera and serial released\")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "PyLabRobot (Aravis)", - "language": "python", - "name": "pylabrobot-workspace" - }, - "language_info": { - "name": "python", - "version": "3.12.0" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} \ No newline at end of file From cbfd03123de2b11eb223a4a17f77f073c6ce4589 Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Fri, 3 Apr 2026 13:27:31 +0200 Subject: [PATCH 3/6] Split Cytation device classes into separate files, fix Cytation1 bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move Cytation5 to cytation5.py — takes backend param (PySpin or Aravis) - Move Cytation1 to cytation1.py — takes backend param, fixes bug where BioTekBackend (no capture()) was wired to Microscopy capability - cytation.py keeps CytationBackend + re-exports devices for backwards compat - __init__.py updated to import from new files Both device classes now accept a backend parameter instead of creating one internally, so users can choose CytationBackend (PySpin) or CytationAravisBackend (Aravis). Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/agilent/biotek/__init__.py | 9 +-- pylabrobot/agilent/biotek/cytation.py | 105 +------------------------ pylabrobot/agilent/biotek/cytation1.py | 87 ++++++++++++++++++++ pylabrobot/agilent/biotek/cytation5.py | 102 ++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 108 deletions(-) create mode 100644 pylabrobot/agilent/biotek/cytation1.py create mode 100644 pylabrobot/agilent/biotek/cytation5.py diff --git a/pylabrobot/agilent/biotek/__init__.py b/pylabrobot/agilent/biotek/__init__.py index ea6fce622a4..73af3dfa11e 100644 --- a/pylabrobot/agilent/biotek/__init__.py +++ b/pylabrobot/agilent/biotek/__init__.py @@ -1,9 +1,6 @@ from .biotek import BioTekBackend -from .cytation import ( - Cytation1, - Cytation5, - CytationBackend, - CytationImagingConfig, -) +from .cytation import CytationBackend, CytationImagingConfig +from .cytation1 import Cytation1 +from .cytation5 import Cytation5 from .el406 import EL406, EL406Driver, EL406PlateWashingBackend, EL406ShakingBackend from .synergy_h1 import SynergyH1, SynergyH1Backend diff --git a/pylabrobot/agilent/biotek/cytation.py b/pylabrobot/agilent/biotek/cytation.py index a24fb5151cb..1e44766628d 100644 --- a/pylabrobot/agilent/biotek/cytation.py +++ b/pylabrobot/agilent/biotek/cytation.py @@ -885,107 +885,8 @@ def image_size(magnification: float) -> Tuple[float, float]: # --------------------------------------------------------------------------- -# Devices +# Backwards compatibility — device classes moved to cytation5.py / cytation1.py # --------------------------------------------------------------------------- - -class Cytation5(Resource, Device): - """Agilent BioTek Cytation 5 — plate reader + imager.""" - - def __init__( - self, - name: str, - device_id: Optional[str] = None, - imaging_config: Optional[CytationImagingConfig] = None, - size_x: float = 0.0, # TODO: measure - size_y: float = 0.0, # TODO: measure - size_z: float = 0.0, # TODO: measure - ): - backend = CytationBackend(device_id=device_id, imaging_config=imaging_config) - Resource.__init__( - self, - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - model="Agilent BioTek Cytation 5", - ) - Device.__init__(self, driver=backend) - self.driver: CytationBackend = backend - self.absorbance = Absorbance(backend=backend) - self.luminescence = Luminescence(backend=backend) - self.fluorescence = Fluorescence(backend=backend) - self.microscopy = Microscopy(backend=backend) - self.temperature = TemperatureController(backend=backend) - self._capabilities = [ - self.absorbance, - self.luminescence, - self.fluorescence, - self.microscopy, - self.temperature, - ] - - self.plate_holder = PlateHolder( - name=name + "_plate_holder", - size_x=127.76, - size_y=85.48, - size_z=0, # TODO: measure - pedestal_size_z=0, - child_location=Coordinate.zero(), # TODO: measure - ) - self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) - - def serialize(self) -> dict: - return {**Resource.serialize(self), **Device.serialize(self)} - - async def open(self, slow: bool = False) -> None: - await self.driver.open(slow=slow) - - async def close(self, slow: bool = False) -> None: - await self.driver.close(slow=slow) - - -class Cytation1(Resource, Device): - """Agilent BioTek Cytation 1 — plate reader only (no imager).""" - - def __init__( - self, - name: str, - device_id: Optional[str] = None, - size_x: float = 0.0, # TODO: measure - size_y: float = 0.0, # TODO: measure - size_z: float = 0.0, # TODO: measure - ): - backend = BioTekBackend(device_id=device_id) - Resource.__init__( - self, - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - model="Agilent BioTek Cytation 1", - ) - Device.__init__(self, driver=backend) - self.driver: BioTekBackend = backend - self.microscopy = Microscopy(backend=backend) # type: ignore[arg-type] - self.temperature = TemperatureController(backend=backend) - self._capabilities = [self.microscopy, self.temperature] - - self.plate_holder = PlateHolder( - name=name + "_plate_holder", - size_x=127.76, - size_y=85.48, - size_z=0, # TODO: measure - pedestal_size_z=0, - child_location=Coordinate.zero(), # TODO: measure - ) - self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) - - def serialize(self) -> dict: - return {**Resource.serialize(self), **Device.serialize(self)} - - async def open(self, slow: bool = False) -> None: - await self.driver.open(slow=slow) - - async def close(self, slow: bool = False) -> None: - await self.driver.close(slow=slow) +from .cytation1 import Cytation1 as Cytation1 # noqa: F401 +from .cytation5 import Cytation5 as Cytation5 # noqa: F401 diff --git a/pylabrobot/agilent/biotek/cytation1.py b/pylabrobot/agilent/biotek/cytation1.py new file mode 100644 index 00000000000..202620cfbb8 --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation1.py @@ -0,0 +1,87 @@ +"""Cytation 1 device — imager with temperature control. + +The Cytation 1 has a microscopy camera and temperature control, +but no plate reading capabilities (no absorbance/fluorescence/luminescence). + +The backend must implement MicroscopyBackend (capture). Two options: + - CytationBackend (cytation.py) — uses PySpin for camera + - CytationAravisBackend (cytation_aravis.py) — uses Aravis for camera + +Example:: + + from pylabrobot.agilent.biotek.cytation_aravis import CytationAravisBackend + from pylabrobot.agilent.biotek.cytation1 import Cytation1 + + backend = CytationAravisBackend(camera_serial="22580842") + cytation = Cytation1(name="cytation1", backend=backend) + await cytation.setup() + result = await cytation.microscopy.capture(...) +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from pylabrobot.capabilities.microscopy import Microscopy +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, PlateHolder, Resource + +from .biotek import BioTekBackend + +logger = logging.getLogger(__name__) + + +class Cytation1(Resource, Device): + """Agilent BioTek Cytation 1 — imager with temperature control. + + Takes a backend that provides microscopy via MicroscopyBackend. + Pass either CytationBackend (PySpin) or CytationAravisBackend (Aravis). + + Capabilities: + - microscopy (imaging via camera) + - temperature (incubation) + """ + + def __init__( + self, + name: str, + backend: BioTekBackend, + size_x: float = 0.0, + size_y: float = 0.0, + size_z: float = 0.0, + ): + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Agilent BioTek Cytation 1", + ) + Device.__init__(self, driver=backend) + self.driver: BioTekBackend = backend + + self.microscopy = Microscopy(backend=backend) + self.temperature = TemperatureController(backend=backend) + self._capabilities = [self.microscopy, self.temperature] + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, + size_y=85.48, + size_z=0, + pedestal_size_z=0, + child_location=Coordinate.zero(), + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + async def open(self, slow: bool = False) -> None: + await self.driver.open(slow=slow) + + async def close(self, slow: bool = False) -> None: + await self.driver.close(slow=slow) diff --git a/pylabrobot/agilent/biotek/cytation5.py b/pylabrobot/agilent/biotek/cytation5.py new file mode 100644 index 00000000000..f1a5e0b77a5 --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation5.py @@ -0,0 +1,102 @@ +"""Cytation 5 device — plate reader + imager. + +The Cytation 5 combines plate reading (absorbance, fluorescence, +luminescence) with microscopy imaging. The backend must implement +both BioTekBackend (serial protocol) and MicroscopyBackend (capture). + +Two backend options: + - CytationBackend (cytation.py) — uses PySpin for camera + - CytationAravisBackend (cytation_aravis.py) — uses Aravis for camera + +Example:: + + from pylabrobot.agilent.biotek.cytation_aravis import CytationAravisBackend + from pylabrobot.agilent.biotek.cytation5 import Cytation5 + + backend = CytationAravisBackend(camera_serial="22580842") + cytation = Cytation5(name="cytation5", backend=backend) + await cytation.setup() + result = await cytation.microscopy.capture(...) +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from pylabrobot.capabilities.microscopy import Microscopy +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence +from pylabrobot.capabilities.plate_reading.luminescence import Luminescence +from pylabrobot.capabilities.temperature_controlling import TemperatureController +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, PlateHolder, Resource + +from .biotek import BioTekBackend + +logger = logging.getLogger(__name__) + + +class Cytation5(Resource, Device): + """Agilent BioTek Cytation 5 — plate reader + imager. + + Takes a backend that provides both plate reading (via BioTekBackend) + and microscopy (via MicroscopyBackend). Pass either CytationBackend + (PySpin) or CytationAravisBackend (Aravis). + + Capabilities: + - absorbance, fluorescence, luminescence (plate reading) + - microscopy (imaging via camera) + - temperature (incubation) + """ + + def __init__( + self, + name: str, + backend: BioTekBackend, + size_x: float = 0.0, + size_y: float = 0.0, + size_z: float = 0.0, + ): + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Agilent BioTek Cytation 5", + ) + Device.__init__(self, driver=backend) + self.driver: BioTekBackend = backend + + self.absorbance = Absorbance(backend=backend) + self.luminescence = Luminescence(backend=backend) + self.fluorescence = Fluorescence(backend=backend) + self.microscopy = Microscopy(backend=backend) + self.temperature = TemperatureController(backend=backend) + self._capabilities = [ + self.absorbance, + self.luminescence, + self.fluorescence, + self.microscopy, + self.temperature, + ] + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, + size_y=85.48, + size_z=0, + pedestal_size_z=0, + child_location=Coordinate.zero(), + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + async def open(self, slow: bool = False) -> None: + await self.driver.open(slow=slow) + + async def close(self, slow: bool = False) -> None: + await self.driver.close(slow=slow) From b01a37361d0c15b476a7ee3d338e0f3f85052d0a Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Fri, 3 Apr 2026 14:57:04 +0200 Subject: [PATCH 4/6] Refactor Aravis backend: split driver/backend, fix camera capture, hardware-tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture refactor following STAR pattern: - cytation_aravis_driver.py: CytationAravisDriver (BioTekBackend + AravisCamera) owns connections, optics control, camera control - cytation_aravis_microscopy.py: CytationAravisMicroscopyBackend (MicroscopyBackend) orchestrates capture by calling driver methods - cytation1.py/cytation5.py: device classes with camera="aravis"|"pyspin" switch - Removed monolithic cytation_aravis.py Bugs fixed during hardware testing: - aravis_camera.py: trigger() no longer starts/stops acquisition per frame — matches PySpin pattern where acquisition brackets the capture loop - cytation_aravis_driver.py: led_on intensity zfill(3) → zfill(2) - cytation_aravis_driver.py: _load_filters/_load_objectives use Rick's actual firmware protocol (per-slot query) instead of made-up regex parsing - cytation_aravis_driver.py: all serial commands (set_imaging_mode, set_objective, set_focus, select, set_position, led_on/off) now match cytation.py exactly - cytation_aravis_driver.py: bytes→str decode for firmware responses Hardware-tested on Cytation 1 with BlackFly BFLY-U3-13S2M: - Single capture: brightfield 2.5x, real images with correct exposure - Multi-image tiling: 2x2 grid capture working - Filter/objective discovery: DAPI, GFP, RFP + 4x PL FL, 2.5x FL Zeiss Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/agilent/biotek/__init__.py | 1 + pylabrobot/agilent/biotek/aravis_camera.py | 71 +- pylabrobot/agilent/biotek/cytation1.py | 82 ++- pylabrobot/agilent/biotek/cytation5.py | 110 ++- pylabrobot/agilent/biotek/cytation_aravis.py | 687 ------------------ .../agilent/biotek/cytation_aravis_driver.py | 519 +++++++++++++ .../biotek/cytation_aravis_microscopy.py | 170 +++++ 7 files changed, 856 insertions(+), 784 deletions(-) delete mode 100644 pylabrobot/agilent/biotek/cytation_aravis.py create mode 100644 pylabrobot/agilent/biotek/cytation_aravis_driver.py create mode 100644 pylabrobot/agilent/biotek/cytation_aravis_microscopy.py diff --git a/pylabrobot/agilent/biotek/__init__.py b/pylabrobot/agilent/biotek/__init__.py index 73af3dfa11e..aac942dfdd7 100644 --- a/pylabrobot/agilent/biotek/__init__.py +++ b/pylabrobot/agilent/biotek/__init__.py @@ -2,5 +2,6 @@ from .cytation import CytationBackend, CytationImagingConfig from .cytation1 import Cytation1 from .cytation5 import Cytation5 +from .cytation_aravis_driver import AravisImagingConfig, CytationAravisDriver from .el406 import EL406, EL406Driver, EL406PlateWashingBackend, EL406ShakingBackend from .synergy_h1 import SynergyH1, SynergyH1Backend diff --git a/pylabrobot/agilent/biotek/aravis_camera.py b/pylabrobot/agilent/biotek/aravis_camera.py index acfebdb011e..1d0b6fbde41 100644 --- a/pylabrobot/agilent/biotek/aravis_camera.py +++ b/pylabrobot/agilent/biotek/aravis_camera.py @@ -233,18 +233,11 @@ def stop_acquisition(self) -> None: self._acquiring = False async def trigger(self, timeout_ms: int = 5000) -> np.ndarray: - """Capture a single frame: start → software trigger → grab → stop. + """Capture a single frame: software trigger → grab. - This mirrors CytationBackend._acquire_image() but uses Aravis buffer - management instead of PySpin's GetNextImage(). - - The flow: - 1. Start acquisition (if not already active) - 2. Execute TriggerSoftware GenICam command - 3. Pop a filled buffer from the stream (with timeout) - 4. Copy buffer data to numpy array (Mono8 → uint8) - 5. Push buffer back to pool for reuse - 6. Stop acquisition + Acquisition must already be started via start_acquisition(). + This mirrors PySpin's pattern: start/stop acquisition bracket + the capture loop, not each individual frame. Args: timeout_ms: Maximum time to wait for image buffer, in milliseconds. @@ -254,41 +247,37 @@ async def trigger(self, timeout_ms: int = 5000) -> np.ndarray: numpy.ndarray: Image as 2D uint8 array (height × width), Mono8 format. Raises: - RuntimeError: If camera not initialized or capture times out. + RuntimeError: If camera not initialized, not acquiring, or times out. """ if self._camera is None: raise RuntimeError("Camera is not initialized. Call setup() first.") + if not self._acquiring: + raise RuntimeError( + "Camera is not acquiring. Call start_acquisition() first." + ) - self.start_acquisition() + # Send software trigger command. + self._device.execute_command("TriggerSoftware") - try: - # Send software trigger command. - # This is equivalent to PySpin's TriggerSoftware.Execute(). - self._device.execute_command("TriggerSoftware") - - # Pop the filled buffer from the stream. - # timeout_pop_buffer takes microseconds, so convert from ms. - buffer = self._stream.timeout_pop_buffer(timeout_ms * 1000) - if buffer is None: - raise RuntimeError( - f"Camera capture timed out after {timeout_ms}ms. " - "Is the camera connected and trigger mode configured?" - ) - - # Extract image data and copy to numpy array. - # We must copy because the buffer memory is owned by Aravis and - # will be reused when we push the buffer back to the pool. - data = buffer.get_data() - image = np.frombuffer(data, dtype=np.uint8).reshape( - self._height, self._width - ).copy() - - # Return buffer to pool for reuse. - self._stream.push_buffer(buffer) - - return image - finally: - self.stop_acquisition() + # Pop the filled buffer from the stream. + # timeout_pop_buffer takes microseconds, so convert from ms. + buffer = self._stream.timeout_pop_buffer(timeout_ms * 1000) + if buffer is None: + raise RuntimeError( + f"Camera capture timed out after {timeout_ms}ms. " + "Is the camera connected and trigger mode configured?" + ) + + # Extract image data and copy to numpy array. + data = buffer.get_data() + image = np.frombuffer(data, dtype=np.uint8).reshape( + self._height, self._width + ).copy() + + # Return buffer to pool for reuse. + self._stream.push_buffer(buffer) + + return image async def stop(self) -> None: """Release camera and free all Aravis resources. diff --git a/pylabrobot/agilent/biotek/cytation1.py b/pylabrobot/agilent/biotek/cytation1.py index 202620cfbb8..d5c47af0197 100644 --- a/pylabrobot/agilent/biotek/cytation1.py +++ b/pylabrobot/agilent/biotek/cytation1.py @@ -1,57 +1,72 @@ """Cytation 1 device — imager with temperature control. -The Cytation 1 has a microscopy camera and temperature control, -but no plate reading capabilities (no absorbance/fluorescence/luminescence). - -The backend must implement MicroscopyBackend (capture). Two options: - - CytationBackend (cytation.py) — uses PySpin for camera - - CytationAravisBackend (cytation_aravis.py) — uses Aravis for camera +Follows the STAR pattern: device creates driver internally based on +the ``camera`` parameter, setup() wires capabilities. Example:: - from pylabrobot.agilent.biotek.cytation_aravis import CytationAravisBackend - from pylabrobot.agilent.biotek.cytation1 import Cytation1 + # Aravis (default) + cytation = Cytation1(name="cytation1", camera_serial="22580842") + + # PySpin + cytation = Cytation1(name="cytation1", camera="pyspin") - backend = CytationAravisBackend(camera_serial="22580842") - cytation = Cytation1(name="cytation1", backend=backend) await cytation.setup() result = await cytation.microscopy.capture(...) + await cytation.stop() """ from __future__ import annotations import logging -from typing import Optional +from typing import Literal, Optional from pylabrobot.capabilities.microscopy import Microscopy from pylabrobot.capabilities.temperature_controlling import TemperatureController from pylabrobot.device import Device from pylabrobot.resources import Coordinate, PlateHolder, Resource -from .biotek import BioTekBackend - logger = logging.getLogger(__name__) class Cytation1(Resource, Device): """Agilent BioTek Cytation 1 — imager with temperature control. - Takes a backend that provides microscopy via MicroscopyBackend. - Pass either CytationBackend (PySpin) or CytationAravisBackend (Aravis). + Creates the appropriate driver based on the ``camera`` parameter: + - ``"aravis"`` (default): CytationAravisDriver (Aravis/GenICam) + - ``"pyspin"``: CytationBackend (PySpin/Spinnaker SDK) Capabilities: - - microscopy (imaging via camera) + - microscopy (imaging) - temperature (incubation) """ def __init__( self, name: str, - backend: BioTekBackend, + camera: Literal["aravis", "pyspin"] = "aravis", + camera_serial: Optional[str] = None, + device_id: Optional[str] = None, size_x: float = 0.0, size_y: float = 0.0, size_z: float = 0.0, ): + if camera == "aravis": + from .cytation_aravis_driver import CytationAravisDriver + driver = CytationAravisDriver( + camera_serial=camera_serial, + device_id=device_id, + ) + elif camera == "pyspin": + from .cytation import CytationBackend, CytationImagingConfig + config = CytationImagingConfig(camera_serial_number=camera_serial) + driver = CytationBackend( + device_id=device_id, + imaging_config=config, + ) + else: + raise ValueError(f"Unknown camera backend: {camera!r}. Use 'aravis' or 'pyspin'.") + Resource.__init__( self, name=name, @@ -60,12 +75,12 @@ def __init__( size_z=size_z, model="Agilent BioTek Cytation 1", ) - Device.__init__(self, driver=backend) - self.driver: BioTekBackend = backend + Device.__init__(self, driver=driver) + self.driver = driver + self._camera = camera - self.microscopy = Microscopy(backend=backend) - self.temperature = TemperatureController(backend=backend) - self._capabilities = [self.microscopy, self.temperature] + self.microscopy: Microscopy # set in setup() + self.temperature: TemperatureController # set in setup() self.plate_holder = PlateHolder( name=name + "_plate_holder", @@ -77,6 +92,29 @@ def __init__( ) self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + async def setup(self) -> None: + if self._camera == "aravis": + await self.driver.setup() + self.microscopy = Microscopy(backend=self.driver.microscopy_backend) + else: + await self.driver.setup(use_cam=True) + self.microscopy = Microscopy(backend=self.driver) + + self.temperature = TemperatureController(backend=self.driver) + self._capabilities = [self.microscopy, self.temperature] + + for cap in self._capabilities: + await cap._on_setup() + self._setup_finished = True + logger.info("Cytation1 setup complete (camera=%s)", self._camera) + + async def stop(self) -> None: + for cap in reversed(self._capabilities): + await cap._on_stop() + await self.driver.stop() + self._setup_finished = False + logger.info("Cytation1 stopped") + def serialize(self) -> dict: return {**Resource.serialize(self), **Device.serialize(self)} diff --git a/pylabrobot/agilent/biotek/cytation5.py b/pylabrobot/agilent/biotek/cytation5.py index f1a5e0b77a5..efca82448ac 100644 --- a/pylabrobot/agilent/biotek/cytation5.py +++ b/pylabrobot/agilent/biotek/cytation5.py @@ -1,28 +1,25 @@ """Cytation 5 device — plate reader + imager. -The Cytation 5 combines plate reading (absorbance, fluorescence, -luminescence) with microscopy imaging. The backend must implement -both BioTekBackend (serial protocol) and MicroscopyBackend (capture). - -Two backend options: - - CytationBackend (cytation.py) — uses PySpin for camera - - CytationAravisBackend (cytation_aravis.py) — uses Aravis for camera +Follows the STAR pattern: device creates driver internally based on +the ``camera`` parameter, setup() wires capabilities. Example:: - from pylabrobot.agilent.biotek.cytation_aravis import CytationAravisBackend - from pylabrobot.agilent.biotek.cytation5 import Cytation5 + # Aravis (default) + cytation = Cytation5(name="cytation5", camera_serial="22580842") + + # PySpin + cytation = Cytation5(name="cytation5", camera="pyspin") - backend = CytationAravisBackend(camera_serial="22580842") - cytation = Cytation5(name="cytation5", backend=backend) await cytation.setup() result = await cytation.microscopy.capture(...) + await cytation.stop() """ from __future__ import annotations import logging -from typing import Optional +from typing import Literal, Optional from pylabrobot.capabilities.microscopy import Microscopy from pylabrobot.capabilities.plate_reading.absorbance import Absorbance @@ -32,32 +29,48 @@ from pylabrobot.device import Device from pylabrobot.resources import Coordinate, PlateHolder, Resource -from .biotek import BioTekBackend - logger = logging.getLogger(__name__) class Cytation5(Resource, Device): """Agilent BioTek Cytation 5 — plate reader + imager. - Takes a backend that provides both plate reading (via BioTekBackend) - and microscopy (via MicroscopyBackend). Pass either CytationBackend - (PySpin) or CytationAravisBackend (Aravis). + Creates the appropriate driver based on the ``camera`` parameter: + - ``"aravis"`` (default): CytationAravisDriver (Aravis/GenICam) + - ``"pyspin"``: CytationBackend (PySpin/Spinnaker SDK) Capabilities: - absorbance, fluorescence, luminescence (plate reading) - - microscopy (imaging via camera) + - microscopy (imaging) - temperature (incubation) """ def __init__( self, name: str, - backend: BioTekBackend, + camera: Literal["aravis", "pyspin"] = "aravis", + camera_serial: Optional[str] = None, + device_id: Optional[str] = None, size_x: float = 0.0, size_y: float = 0.0, size_z: float = 0.0, ): + if camera == "aravis": + from .cytation_aravis_driver import CytationAravisDriver + driver = CytationAravisDriver( + camera_serial=camera_serial, + device_id=device_id, + ) + elif camera == "pyspin": + from .cytation import CytationBackend, CytationImagingConfig + config = CytationImagingConfig(camera_serial_number=camera_serial) + driver = CytationBackend( + device_id=device_id, + imaging_config=config, + ) + else: + raise ValueError(f"Unknown camera backend: {camera!r}. Use 'aravis' or 'pyspin'.") + Resource.__init__( self, name=name, @@ -66,21 +79,15 @@ def __init__( size_z=size_z, model="Agilent BioTek Cytation 5", ) - Device.__init__(self, driver=backend) - self.driver: BioTekBackend = backend - - self.absorbance = Absorbance(backend=backend) - self.luminescence = Luminescence(backend=backend) - self.fluorescence = Fluorescence(backend=backend) - self.microscopy = Microscopy(backend=backend) - self.temperature = TemperatureController(backend=backend) - self._capabilities = [ - self.absorbance, - self.luminescence, - self.fluorescence, - self.microscopy, - self.temperature, - ] + Device.__init__(self, driver=driver) + self.driver = driver + self._camera = camera + + self.absorbance: Absorbance # set in setup() + self.luminescence: Luminescence # set in setup() + self.fluorescence: Fluorescence # set in setup() + self.microscopy: Microscopy # set in setup() + self.temperature: TemperatureController # set in setup() self.plate_holder = PlateHolder( name=name + "_plate_holder", @@ -92,6 +99,41 @@ def __init__( ) self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + async def setup(self) -> None: + if self._camera == "aravis": + await self.driver.setup() + self.microscopy = Microscopy(backend=self.driver.microscopy_backend) + else: + await self.driver.setup(use_cam=True) + self.microscopy = Microscopy(backend=self.driver) + + # Plate reading + temperature use the driver directly + # (BioTekBackend implements these backend ABCs) + self.absorbance = Absorbance(backend=self.driver) + self.luminescence = Luminescence(backend=self.driver) + self.fluorescence = Fluorescence(backend=self.driver) + self.temperature = TemperatureController(backend=self.driver) + + self._capabilities = [ + self.absorbance, + self.luminescence, + self.fluorescence, + self.microscopy, + self.temperature, + ] + + for cap in self._capabilities: + await cap._on_setup() + self._setup_finished = True + logger.info("Cytation5 setup complete (camera=%s)", self._camera) + + async def stop(self) -> None: + for cap in reversed(self._capabilities): + await cap._on_stop() + await self.driver.stop() + self._setup_finished = False + logger.info("Cytation5 stopped") + def serialize(self) -> dict: return {**Resource.serialize(self), **Device.serialize(self)} diff --git a/pylabrobot/agilent/biotek/cytation_aravis.py b/pylabrobot/agilent/biotek/cytation_aravis.py deleted file mode 100644 index 018719296d3..00000000000 --- a/pylabrobot/agilent/biotek/cytation_aravis.py +++ /dev/null @@ -1,687 +0,0 @@ -"""Cytation 5 backend using Aravis instead of PySpin for camera control. - -Layer: PLR MicroscopyBackend implementation -Role: Drop-in replacement for CytationBackend — same serial protocol for - filter wheel, objective turret, focus motor, LED, and well positioning, - but uses AravisCamera for image acquisition instead of PySpin. -Adjacent layers: - - Above: PLR Microscopy capability calls capture() - - Below: BioTekBackend serial IO (filter/objective/focus/LED) + - AravisCamera (image acquisition via Aravis/GenICam) - -This module exists because CytationBackend (cytation.py) imports PySpin at -module level. Inheriting from CytationBackend would require PySpin to be -installed — defeating the purpose. Instead, CytationAravisBackend inherits -directly from BioTekBackend + MicroscopyBackend and copies the serial protocol -methods from CytationBackend. The camera methods delegate to AravisCamera. - -The serial protocol methods (filter wheel, objectives, focus, LED, well -positioning) are copied from cytation.py (PLR commit 226e6d41). These -methods use self.io (BioTekBackend serial) and do not touch PySpin. - -Architecture label: **[Proposed]** — Aravis as alternative to PySpin for PLR. -""" - -from __future__ import annotations - -import asyncio -import logging -import math -import re -import time -from dataclasses import dataclass, field -from typing import List, Literal, Optional, Tuple, Union - -import numpy as np - -from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.capabilities.microscopy.backend import MicroscopyBackend -from pylabrobot.capabilities.microscopy.standard import ( - Exposure, - FocalPosition, - Gain, - Image, - ImagingMode, - ImagingResult, - Objective, -) -from pylabrobot.resources.plate import Plate -from pylabrobot.serializer import SerializableMixin - -from .aravis_camera import AravisCamera -from .biotek import BioTekBackend - -logger = logging.getLogger(__name__) - - -@dataclass -class AravisImagingConfig: - """Imaging configuration for CytationAravisBackend. - - Equivalent to CytationImagingConfig but without PySpin dependencies. - Defines filter wheel positions, objective positions, and camera serial. - """ - - camera_serial_number: Optional[str] = None - filters: Optional[List[Optional[ImagingMode]]] = None - objectives: Optional[List[Optional[Objective]]] = None - max_image_read_attempts: int = 10 - image_read_delay: float = 0.3 - - -class CytationAravisBackend(BioTekBackend, MicroscopyBackend): - """Cytation 5 backend with Aravis camera instead of PySpin. - - This class implements PLR's MicroscopyBackend.capture() by orchestrating: - 1. Well positioning (serial protocol → BioTekBackend) - 2. Filter/objective/focus/LED (serial protocol → copied from CytationBackend) - 3. Exposure/gain (AravisCamera → GenICam nodes) - 4. Image acquisition (AravisCamera → Aravis buffer) - - The serial protocol code is copied from CytationBackend (PLR commit 226e6d41). - Camera operations delegate to AravisCamera. - - Usage: - backend = CytationAravisBackend(camera_serial="12345678") - cytation = Cytation5(backend=backend) - await cytation.setup() - result = await cytation.microscopy.capture(...) - """ - - def __init__( - self, - camera_serial: Optional[str] = None, - timeout: float = 20, - device_id: Optional[str] = None, - imaging_config: Optional[AravisImagingConfig] = None, - ) -> None: - super().__init__( - timeout=timeout, - device_id=device_id, - human_readable_device_name="Agilent BioTek Cytation (Aravis)", - ) - - self._aravis = AravisCamera() - self._camera_serial = camera_serial - self.imaging_config = imaging_config or AravisImagingConfig( - camera_serial_number=camera_serial - ) - - # Imaging state (mirrors CytationBackend) - self._filters: Optional[List[Optional[ImagingMode]]] = ( - self.imaging_config.filters - ) - self._objectives: Optional[List[Optional[Objective]]] = ( - self.imaging_config.objectives - ) - self._exposure: Optional[Exposure] = None - self._focal_height: Optional[FocalPosition] = None - self._gain: Optional[Gain] = None - self._imaging_mode: Optional[ImagingMode] = None - self._row: Optional[int] = None - self._column: Optional[int] = None - self._pos_x: Optional[float] = None - self._pos_y: Optional[float] = None - self._objective: Optional[Objective] = None - self._acquiring = False - - @property - def filters(self) -> List[Optional[ImagingMode]]: - if self._filters is None: - raise RuntimeError("Filters not loaded. Call setup() first.") - return self._filters - - @property - def objectives(self) -> List[Optional[Objective]]: - if self._objectives is None: - raise RuntimeError("Objectives not loaded. Call setup() first.") - return self._objectives - - # ─── Lifecycle ─────────────────────────────────────────────────────── - - async def setup(self, use_cam: bool = True) -> None: - """Set up serial connection and camera. - - Args: - use_cam: If True (default), initialize the Aravis camera. - Set to False for serial-only operations (plate reading). - """ - logger.info("%s setting up", self.__class__.__name__) - await super().setup() - - # Load filter and objective configuration from Cytation via serial - if self._filters is None: - await self._load_filters() - if self._objectives is None: - await self._load_objectives() - - if use_cam: - serial = ( - self._camera_serial - or (self.imaging_config.camera_serial_number if self.imaging_config else None) - ) - if serial is None: - raise RuntimeError( - "No camera serial number provided. Pass camera_serial to constructor " - "or set imaging_config.camera_serial_number." - ) - try: - await self._aravis.setup(serial) - except Exception: - try: - await self.stop() - except Exception: - pass - raise - - async def stop(self) -> None: - """Stop camera and serial connection.""" - logger.info("%s stopping", self.__class__.__name__) - - if self._acquiring: - self.stop_acquisition() - - await self._aravis.stop() - await super().stop() - - self._objectives = None - self._filters = None - self._clear_imaging_state() - - def _clear_imaging_state(self) -> None: - self._exposure = None - self._focal_height = None - self._gain = None - self._imaging_mode = None - self._row = None - self._column = None - self._pos_x = None - self._pos_y = None - - # ─── Filter & Objective Loading (copied from CytationBackend) ──────── - - async def _load_filters(self) -> None: - """Load filter wheel configuration from Cytation via serial. - - Copied from CytationBackend (PLR commit 226e6d41). - Reads 4 filter positions and maps Cytation codes to ImagingMode enums. - """ - self._filters = [] - cytation_code2imaging_mode = { - 1225121: ImagingMode.C377_647, - 1225123: ImagingMode.C400_647, - 1225113: ImagingMode.C469_593, - 1225109: ImagingMode.ACRIDINE_ORANGE, - 1225107: ImagingMode.CFP, - 1225118: ImagingMode.CFP_FRET_V2, - 1225110: ImagingMode.CFP_YFP_FRET, - 1225119: ImagingMode.CFP_YFP_FRET_V2, - 1225112: ImagingMode.CHLOROPHYLL_A, - 1225105: ImagingMode.CY5, - 1225114: ImagingMode.CY5_5, - 1225106: ImagingMode.CY7, - 1225100: ImagingMode.DAPI, - 1225101: ImagingMode.GFP, - 1225116: ImagingMode.GFP_CY5, - 1225122: ImagingMode.OXIDIZED_ROGFP2, - 1225111: ImagingMode.PROPIDIUM_IODIDE, - 1225103: ImagingMode.RFP, - 1225117: ImagingMode.RFP_CY5, - 1225115: ImagingMode.TAG_BFP, - 1225102: ImagingMode.TEXAS_RED, - 1225104: ImagingMode.YFP, - } - for spot in range(1, 5): - configuration = await self.send_command("i", f"q{spot}") - assert configuration is not None - parts = configuration.decode().strip().split(" ") - if len(parts) == 1: - self._filters.append(None) - else: - cytation_code = int(parts[0]) - if cytation_code not in cytation_code2imaging_mode: - self._filters.append(None) - else: - self._filters.append(cytation_code2imaging_mode[cytation_code]) - - async def _load_objectives(self) -> None: - """Load objective turret configuration from Cytation via serial. - - Copied from CytationBackend (PLR commit 226e6d41). - Reads objective positions and maps to Objective enums. - """ - self._objectives = [] - if self.version.startswith("1"): - weird_encoding = { - 0x00: " ", 0x14: ".", 0x15: "/", 0x16: "0", 0x17: "1", - 0x18: "2", 0x19: "3", 0x20: "4", 0x21: "5", 0x22: "6", - 0x23: "7", 0x24: "8", 0x25: "9", 0x33: "A", 0x34: "B", - 0x35: "C", 0x36: "D", 0x37: "E", 0x38: "F", 0x39: "G", - 0x40: "H", 0x41: "I", 0x42: "J", 0x43: "K", 0x44: "L", - 0x45: "M", 0x46: "N", 0x47: "O", 0x48: "P", 0x49: "Q", - 0x50: "R", 0x51: "S", 0x52: "T", 0x53: "U", 0x54: "V", - 0x55: "W", 0x56: "X", 0x57: "Y", 0x58: "Z", - } - part_number2objective = { - "uplsapo 40x2": Objective.O_40X_PL_APO, - "lucplfln 60X": Objective.O_60X_PL_FL, - "uplfln 4x": Objective.O_4X_PL_FL, - "lucplfln 20xph": Objective.O_20X_PL_FL_Phase, - "lucplfln 40xph": Objective.O_40X_PL_FL_Phase, - "u plan": Objective.O_2_5X_PL_ACH_Meiji, - "uplfln 10xph": Objective.O_10X_PL_FL_Phase, - "plapon 1.25x": Objective.O_1_25X_PL_APO, - "uplfln 10x": Objective.O_10X_PL_FL, - "uplfln 60xoi": Objective.O_60X_OIL_PL_FL, - "pln 4x": Objective.O_4X_PL_ACH, - "pln 40x": Objective.O_40X_PL_ACH, - "lucplfln 40x": Objective.O_40X_PL_FL, - "ec-h-plan/2x": Objective.O_2X_PL_ACH_Motic, - "uplfln 100xO2": Objective.O_100X_OIL_PL_FL, - "uplfln 4xph": Objective.O_4X_PL_FL_Phase, - "lucplfln 20X": Objective.O_20X_PL_FL, - "pln 20x": Objective.O_20X_PL_ACH, - "fluar 2.5x/0.12": Objective.O_2_5X_FL_Zeiss, - "uplsapo 100xo": Objective.O_100X_OIL_PL_APO, - "plapon 60xo": Objective.O_60X_OIL_PL_APO, - "uplsapo 20x": Objective.O_20X_PL_APO, - } - for spot in [1, 2]: - configuration = await self.send_command("i", f"o{spot}") - if configuration is None: - raise RuntimeError("Failed to load objective configuration") - middle_part = re.split( - r"\s+", configuration.rstrip(b"\x03").decode("utf-8") - )[1] - if middle_part == "0000": - self._objectives.append(None) - else: - part_number = "".join( - [weird_encoding[x] for x in bytes.fromhex(middle_part)] - ) - self._objectives.append( - part_number2objective.get(part_number.lower()) - ) - elif self.version.startswith("2"): - annulus_part_number2objective = { - 1320520: Objective.O_4X_PL_FL_Phase, - 1320521: Objective.O_20X_PL_FL_Phase, - 1322026: Objective.O_40X_PL_FL_Phase, - } - for spot in range(1, 7): - configuration = await self.send_command("i", f"h{spot + 1}") - assert configuration is not None - if configuration.startswith(b"****"): - self._objectives.append(None) - else: - code = int( - configuration.decode("latin").strip().split(" ")[0] - ) - self._objectives.append( - annulus_part_number2objective.get(code) - ) - self._objective = None - - # ─── Acquisition Control ───────────────────────────────────────────── - - def start_acquisition(self) -> None: - """Start camera acquisition.""" - if self._acquiring: - return - self._aravis.start_acquisition() - self._acquiring = True - - def stop_acquisition(self) -> None: - """Stop camera acquisition.""" - if not self._acquiring: - return - self._aravis.stop_acquisition() - self._acquiring = False - - # ─── Camera Parameters ─────────────────────────────────────────────── - - async def set_exposure(self, exposure: Exposure) -> None: - """Set exposure time (milliseconds) or 'machine-auto'. - - Mirrors CytationBackend.set_exposure(). - """ - if exposure == self._exposure: - return - - if isinstance(exposure, str): - if exposure == "machine-auto": - await self.set_auto_exposure("continuous") - self._exposure = "machine-auto" - return - raise ValueError("exposure must be a number or 'machine-auto'") - - await self._aravis.set_exposure(float(exposure)) - self._exposure = exposure - - async def set_gain(self, gain: Gain) -> None: - """Set gain value or 'machine-auto'. - - Mirrors CytationBackend.set_gain(). - """ - if gain == self._gain: - return - - if gain == "machine-auto": - # Aravis auto-gain via GainAuto node - self._aravis._device.set_string_feature_value("GainAuto", "Continuous") - self._gain = "machine-auto" - return - - await self._aravis.set_gain(float(gain)) - self._gain = gain - - async def set_auto_exposure( - self, auto_exposure: Literal["off", "once", "continuous"] - ) -> None: - """Set auto-exposure mode. Delegates to AravisCamera.""" - await self._aravis.set_auto_exposure(auto_exposure) - - # ─── Image Acquisition ─────────────────────────────────────────────── - - async def _acquire_image(self) -> Image: - """Capture a single frame via AravisCamera. - - Mirrors CytationBackend._acquire_image(). Includes retry logic - matching the original's max_image_read_attempts pattern. - """ - max_attempts = self.imaging_config.max_image_read_attempts - delay = self.imaging_config.image_read_delay - - for attempt in range(max_attempts): - try: - image = await self._aravis.trigger(timeout_ms=5000) - return image - except RuntimeError as e: - if attempt < max_attempts - 1: - logger.warning( - "Image capture attempt %d/%d failed: %s", - attempt + 1, - max_attempts, - e, - ) - await asyncio.sleep(delay) - else: - raise - - raise RuntimeError("Image capture failed after all attempts") - - # ─── Serial Protocol (copied from CytationBackend, PLR commit 226e6d41) ─ - - def _imaging_mode_code(self, mode: ImagingMode) -> int: - """Map ImagingMode to Cytation filter wheel code. - - Brightfield and phase contrast use code 5. - Fluorescence modes use their index in the filter list (1-based). - """ - if mode in (ImagingMode.BRIGHTFIELD, ImagingMode.PHASE_CONTRAST): - return 5 - return self.filters.index(mode) + 1 - - def _objective_code(self, objective: Objective) -> int: - """Map Objective to Cytation turret position (1-based).""" - return self.objectives.index(objective) + 1 - - async def set_imaging_mode( - self, mode: ImagingMode, led_intensity: int = 10 - ) -> None: - """Set filter wheel position and LED. Copied from CytationBackend.""" - if mode == self._imaging_mode: - logger.debug("Imaging mode is already set to %s", mode) - await self.led_on(intensity=led_intensity) - return - - if mode == ImagingMode.COLOR_BRIGHTFIELD: - raise NotImplementedError("Color brightfield imaging not implemented yet") - - await self.led_off() - filter_index = self._imaging_mode_code(mode) - - if self.version.startswith("1"): - if mode == ImagingMode.PHASE_CONTRAST: - raise NotImplementedError( - "Phase contrast not implemented on Cytation1" - ) - elif mode == ImagingMode.BRIGHTFIELD: - await self.send_command("Y", "P0c05") - await self.send_command("Y", "P0f02") - else: - await self.send_command("Y", f"P0c{filter_index:02}") - await self.send_command("Y", "P0f01") - else: - if mode == ImagingMode.PHASE_CONTRAST: - await self.send_command("Y", "P1120") - await self.send_command("Y", "P0d05") - await self.send_command("Y", "P1002") - elif mode == ImagingMode.BRIGHTFIELD: - await self.send_command("Y", "P1101") - await self.send_command("Y", "P0d05") - await self.send_command("Y", "P1002") - else: - await self.send_command("Y", "P1101") - await self.send_command("Y", f"P0d{filter_index:02}") - await self.send_command("Y", "P1001") - - self._imaging_mode = mode - await self.led_on(intensity=led_intensity) - - async def set_objective(self, objective: Objective) -> None: - """Move objective turret. Copied from CytationBackend.""" - if objective == self._objective: - return - - objective_code = self._objective_code(objective) - - if self.version.startswith("1"): - await self.send_command("Y", f"P0d{objective_code:02}", timeout=60) - else: - await self.send_command("Y", f"P0e{objective_code:02}", timeout=60) - - self._objective = objective - self._imaging_mode = None - - async def set_focus(self, focal_position: FocalPosition) -> None: - """Set focus motor position (mm). Copied from CytationBackend.""" - if focal_position == "machine-auto": - raise ValueError( - "focal_position cannot be 'machine-auto'. " - "Use the PLR Imager universal autofocus instead." - ) - - if focal_position == self._focal_height: - return - - slope, intercept = (10.637991436186072, 1.0243013203461762) - focus_integer = int( - focal_position + intercept + slope * focal_position * 1000 - ) - focus_str = str(focus_integer).zfill(5) - - if self._imaging_mode is None: - raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") - imaging_mode_code = self._imaging_mode_code(self._imaging_mode) - await self.send_command("i", f"F{imaging_mode_code}0{focus_str}") - - self._focal_height = focal_position - - async def led_on(self, intensity: int = 10) -> None: - """Turn on LED. Copied from CytationBackend.""" - if not 1 <= intensity <= 10: - raise ValueError("intensity must be between 1 and 10") - intensity_str = str(intensity).zfill(2) - if self._imaging_mode is None: - raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") - imaging_mode_code = self._imaging_mode_code(self._imaging_mode) - await self.send_command("i", f"L0{imaging_mode_code}{intensity_str}") - - async def led_off(self) -> None: - """Turn off LED. Copied from CytationBackend.""" - await self.send_command("i", "L0001") - - async def select(self, row: int, column: int) -> None: - """Move to well position. Copied from CytationBackend.""" - if row == self._row and column == self._column: - return - row_str = str(row).zfill(2) - column_str = str(column).zfill(2) - await self.send_command("Y", f"W6{row_str}{column_str}") - self._row, self._column = row, column - self._pos_x, self._pos_y = None, None - await self.set_position(0, 0) - - async def set_position(self, x: float, y: float) -> None: - """Set precise position within well. Adapted from CytationBackend.""" - if self._imaging_mode is None: - raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") - - if x == self._pos_x and y == self._pos_y: - return - - x_str = str(round(x * 100 * 0.984)).zfill(6) - y_str = str(round(y * 100 * 0.984)).zfill(6) - - if self._row is None or self._column is None: - raise ValueError("Row and column not set. Call select() first.") - row_str = str(self._row).zfill(2) - column_str = str(self._column).zfill(2) - - if self._objective is None: - raise ValueError("Objective not set. Call set_objective() first.") - objective_code = self._objective_code(self._objective) - imaging_mode_code = self._imaging_mode_code(self._imaging_mode) - await self.send_command( - "Y", - f"Z{objective_code}{imaging_mode_code}6{row_str}{column_str}" - f"{y_str}{x_str}", - ) - - self._pos_x, self._pos_y = x, y - - # set_plate() is inherited from BioTekBackend — sends plate geometry - # (well positions, plate dimensions, plate height) to the Cytation via - # serial command "y". Do NOT override — the Cytation needs this to - # position the stage correctly. - - # ─── Vendor Params ────────────────────────────────────────────────── - - @dataclass - class CaptureParams(BackendParams): - """Aravis-specific capture parameters. - - Passed via ``backend_params`` in ``Microscopy.capture()``. - Mirrors CytationBackend.CaptureParams but without PySpin-specific - fields (color_processing_algorithm, pixel_format). - """ - - led_intensity: int = 10 - coverage: Union[Literal["full"], Tuple[int, int]] = (1, 1) - center_position: Optional[Tuple[float, float]] = None - auto_stop_acquisition: bool = True - - # ─── MicroscopyBackend.capture() ───────────────────────────────────── - - async def capture( - self, - row: int, - column: int, - mode: ImagingMode, - objective: Objective, - exposure_time: Exposure, - focal_height: FocalPosition, - gain: Gain, - plate: Plate, - backend_params: Optional[SerializableMixin] = None, - ) -> ImagingResult: - """Capture image(s) from a well. Implements MicroscopyBackend.capture(). - - Orchestrates the full imaging pipeline: - 1. Set plate geometry - 2. Start camera acquisition - 3. Set objective (turret motor, serial) - 4. Set imaging mode / filter (filter wheel, serial) - 5. Select well (stage motor, serial) - 6. Set exposure and gain (AravisCamera → GenICam) - 7. Set focal height (focus motor, serial) - 8. Trigger and grab image (AravisCamera → Aravis buffer) - 9. Return ImagingResult - - This mirrors CytationBackend.capture() but uses AravisCamera - instead of PySpin for image acquisition. - """ - if not isinstance(backend_params, self.CaptureParams): - backend_params = CytationAravisBackend.CaptureParams() - - led_intensity = backend_params.led_intensity - coverage = backend_params.coverage - center_position = backend_params.center_position - auto_stop_acquisition = backend_params.auto_stop_acquisition - - await self.set_plate(plate) - - if not self._acquiring: - self.start_acquisition() - - try: - await self.set_objective(objective) - await self.set_imaging_mode(mode, led_intensity=led_intensity) - await self.select(row, column) - await self.set_exposure(exposure_time) - await self.set_gain(gain) - await self.set_focus(focal_height) - - if center_position is not None: - await self.set_position(center_position[0], center_position[1]) - - images: List[Image] = [] - - if coverage == (1, 1): - # Single image capture - image = await self._acquire_image() - images.append(image) - else: - # Multi-position tiling (matches CytationBackend pattern) - if self._objective is None: - raise RuntimeError("Objective not set.") - magnification = self._objective.magnification - - # Image field of view by magnification (mm) - fov_map = {4: 3.474, 20: 0.694, 40: 0.347} - fov = fov_map.get(int(magnification), 3.474) - - if coverage == "full": - first_well = plate.get_item(0) - well_w = first_well.get_size_x() - well_h = first_well.get_size_y() - rows_n = math.ceil(well_h / fov) - cols_n = math.ceil(well_w / fov) - else: - rows_n, cols_n = coverage - - cx = center_position[0] if center_position else 0.0 - cy = center_position[1] if center_position else 0.0 - - for yi in range(rows_n): - for xi in range(cols_n): - x_pos = (xi - (cols_n - 1) / 2) * fov + cx - y_pos = -(yi - (rows_n - 1) / 2) * fov + cy - await self.set_position(x=x_pos, y=y_pos) - image = await self._acquire_image() - images.append(image) - - finally: - await self.led_off() - if auto_stop_acquisition: - self.stop_acquisition() - - exposure_ms = await self._aravis.get_exposure() - focal_height_val = float(self._focal_height) if self._focal_height else 0.0 - - return ImagingResult( - images=images, - exposure_time=exposure_ms, - focal_height=focal_height_val, - ) diff --git a/pylabrobot/agilent/biotek/cytation_aravis_driver.py b/pylabrobot/agilent/biotek/cytation_aravis_driver.py new file mode 100644 index 00000000000..28573b6f2e9 --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation_aravis_driver.py @@ -0,0 +1,519 @@ +"""CytationAravisDriver — connection + optics + camera for the Cytation using Aravis. + +Extends BioTekBackend (serial IO) with AravisCamera for image acquisition. +This is the driver layer — it owns the connections and low-level commands. +The MicroscopyBackend (capture orchestration) is created during setup() +and accessed via ``self.microscopy_backend``. + +Follows the STARDriver pattern: the driver creates backends during setup(), +the device reads them to wire capabilities. + +Layer: Driver (connection + low-level commands) +Adjacent layers: + - Above: Cytation1/Cytation5 device reads driver.microscopy_backend + - Below: BioTekBackend serial IO + AravisCamera (GenICam/USB3 Vision) +""" + +from __future__ import annotations + +import asyncio +import logging +import re +import time +from dataclasses import dataclass +from typing import List, Literal, Optional, Tuple, Union + +import numpy as np + +from pylabrobot.capabilities.microscopy.standard import ( + Exposure, + FocalPosition, + Gain, + Image, + ImagingMode, + Objective, +) + +from .aravis_camera import AravisCamera +from .biotek import BioTekBackend +from .cytation_aravis_microscopy import CytationAravisMicroscopyBackend + +logger = logging.getLogger(__name__) + + +@dataclass +class AravisImagingConfig: + """Imaging configuration for the Cytation with Aravis camera. + + Defines filter wheel positions, objective positions, and camera serial. + """ + + camera_serial_number: Optional[str] = None + filters: Optional[List[Optional[ImagingMode]]] = None + objectives: Optional[List[Optional[Objective]]] = None + max_image_read_attempts: int = 10 + image_read_delay: float = 0.3 + + +class CytationAravisDriver(BioTekBackend): + """Driver for the Cytation using Aravis camera instead of PySpin. + + Extends BioTekBackend with: + - AravisCamera for image acquisition (replaces PySpin) + - Optics control methods (filter wheel, objectives, focus, LED, positioning) + - Camera control methods (exposure, gain, auto-exposure, trigger) + + During setup(), creates a CytationAravisMicroscopyBackend that the + device class reads to wire the Microscopy capability. + + Usage:: + + driver = CytationAravisDriver(camera_serial="22580842") + await driver.setup() + # driver.microscopy_backend is now available for Microscopy(backend=...) + """ + + def __init__( + self, + camera_serial: Optional[str] = None, + timeout: float = 20, + device_id: Optional[str] = None, + imaging_config: Optional[AravisImagingConfig] = None, + ) -> None: + super().__init__( + timeout=timeout, + device_id=device_id, + human_readable_device_name="Agilent BioTek Cytation (Aravis)", + ) + + self.camera = AravisCamera() + self._camera_serial = camera_serial + self.imaging_config = imaging_config or AravisImagingConfig( + camera_serial_number=camera_serial + ) + + # Imaging state + self._filters: Optional[List[Optional[ImagingMode]]] = ( + self.imaging_config.filters + ) + self._objectives: Optional[List[Optional[Objective]]] = ( + self.imaging_config.objectives + ) + self._exposure: Optional[Exposure] = None + self._focal_height: Optional[FocalPosition] = None + self._gain: Optional[Gain] = None + self._imaging_mode: Optional[ImagingMode] = None + self._row: Optional[int] = None + self._column: Optional[int] = None + self._pos_x: Optional[float] = None + self._pos_y: Optional[float] = None + self._objective: Optional[Objective] = None + self._acquiring = False + + # Created during setup() + self.microscopy_backend: CytationAravisMicroscopyBackend + + @property + def filters(self) -> List[Optional[ImagingMode]]: + if self._filters is None: + raise RuntimeError("Filters not loaded. Call setup() first.") + return self._filters + + @property + def objectives(self) -> List[Optional[Objective]]: + if self._objectives is None: + raise RuntimeError("Objectives not loaded. Call setup() first.") + return self._objectives + + # ─── Lifecycle ─────────────────────────────────────────────────────── + + async def setup(self, use_cam: bool = True) -> None: + """Set up serial connection and camera.""" + logger.info("CytationAravisDriver setting up") + + await super().setup() + + if use_cam: + serial = ( + self._camera_serial + or (self.imaging_config.camera_serial_number if self.imaging_config else None) + ) + await self.camera.setup(serial_number=serial) + logger.info("Camera connected: %s", self.camera.get_device_info()) + + if self._filters is None: + await self._load_filters() + + if self._objectives is None: + await self._load_objectives() + + # Create microscopy backend (device reads this to wire Microscopy capability) + self.microscopy_backend = CytationAravisMicroscopyBackend(driver=self) + + logger.info("CytationAravisDriver setup complete") + + async def stop(self) -> None: + """Disconnect camera and serial.""" + self._clear_imaging_state() + try: + await self.camera.stop() + except Exception: + logger.exception("Error stopping camera") + await super().stop() + logger.info("CytationAravisDriver stopped") + + def _clear_imaging_state(self) -> None: + self._exposure = None + self._focal_height = None + self._gain = None + self._imaging_mode = None + self._row = None + self._column = None + self._pos_x = None + self._pos_y = None + self._objective = None + self._acquiring = False + + # ─── Filter / Objective Discovery ──────────────────────────────────── + + async def _load_filters(self) -> None: + """Discover installed filter cube positions from firmware. + + Queries each slot individually with command ``i q{slot}``. + Uses the same code mapping as CytationBackend (cytation.py). + """ + cytation_code2imaging_mode = { + 1225121: ImagingMode.C377_647, + 1225123: ImagingMode.C400_647, + 1225113: ImagingMode.C469_593, + 1225109: ImagingMode.ACRIDINE_ORANGE, + 1225107: ImagingMode.CFP, + 1225118: ImagingMode.CFP_FRET_V2, + 1225110: ImagingMode.CFP_YFP_FRET, + 1225119: ImagingMode.CFP_YFP_FRET_V2, + 1225112: ImagingMode.CHLOROPHYLL_A, + 1225105: ImagingMode.CY5, + 1225114: ImagingMode.CY5_5, + 1225106: ImagingMode.CY7, + 1225100: ImagingMode.DAPI, + 1225101: ImagingMode.GFP, + 1225116: ImagingMode.GFP_CY5, + 1225122: ImagingMode.OXIDIZED_ROGFP2, + 1225111: ImagingMode.PROPIDIUM_IODIDE, + 1225103: ImagingMode.RFP, + 1225117: ImagingMode.RFP_CY5, + 1225115: ImagingMode.TAG_BFP, + 1225102: ImagingMode.TEXAS_RED, + 1225104: ImagingMode.YFP, + } + + self._filters = [] + for slot in range(1, 5): + configuration = await self.send_command("i", f"q{slot}") + assert configuration is not None + parts = configuration.decode().strip().split(" ") + if len(parts) == 1: + self._filters.append(None) + else: + cytation_code = int(parts[0]) + self._filters.append( + cytation_code2imaging_mode.get(cytation_code, None) + ) + + logger.info("Loaded filters: %s", self._filters) + + async def _load_objectives(self) -> None: + """Discover installed objective positions from firmware. + + Queries each slot individually. Uses the same weird encoding + and part number mapping as CytationBackend (cytation.py). + Firmware version 1.x uses ``i o{slot}``, version 2.x uses ``i h{slot}``. + """ + weird_encoding = { + 0x00: " ", 0x14: ".", 0x15: "/", + 0x16: "0", 0x17: "1", 0x18: "2", 0x19: "3", + 0x20: "4", 0x21: "5", 0x22: "6", 0x23: "7", + 0x24: "8", 0x25: "9", + 0x33: "A", 0x34: "B", 0x35: "C", 0x36: "D", + 0x37: "E", 0x38: "F", 0x39: "G", + 0x40: "H", 0x41: "I", 0x42: "J", 0x43: "K", + 0x44: "L", 0x45: "M", 0x46: "N", 0x47: "O", + 0x48: "P", 0x49: "Q", 0x50: "R", 0x51: "S", + 0x52: "T", 0x53: "U", 0x54: "V", 0x55: "W", + 0x56: "X", 0x57: "Y", 0x58: "Z", + } + part_number2objective = { + "uplsapo 40x2": Objective.O_40X_PL_APO, + "lucplfln 60X": Objective.O_60X_PL_FL, + "uplfln 4x": Objective.O_4X_PL_FL, + "lucplfln 20xph": Objective.O_20X_PL_FL_Phase, + "lucplfln 40xph": Objective.O_40X_PL_FL_Phase, + "u plan": Objective.O_2_5X_PL_ACH_Meiji, + "uplfln 10xph": Objective.O_10X_PL_FL_Phase, + "plapon 1.25x": Objective.O_1_25X_PL_APO, + "uplfln 10x": Objective.O_10X_PL_FL, + "uplfln 60xoi": Objective.O_60X_OIL_PL_FL, + "pln 4x": Objective.O_4X_PL_ACH, + "pln 40x": Objective.O_40X_PL_ACH, + "lucplfln 40x": Objective.O_40X_PL_FL, + "ec-h-plan/2x": Objective.O_2X_PL_ACH_Motic, + "uplfln 100xO2": Objective.O_100X_OIL_PL_FL, + "uplfln 4xph": Objective.O_4X_PL_FL_Phase, + "lucplfln 20X": Objective.O_20X_PL_FL, + "pln 20x": Objective.O_20X_PL_ACH, + "fluar 2.5x/0.12": Objective.O_2_5X_FL_Zeiss, + "uplsapo 100xo": Objective.O_100X_OIL_PL_APO, + "plapon 60xo": Objective.O_60X_OIL_PL_APO, + "uplsapo 20x": Objective.O_20X_PL_APO, + } + + self._objectives = [] + if self.version.startswith("1"): + for slot in [1, 2]: + configuration = await self.send_command("i", f"o{slot}") + if configuration is None: + raise RuntimeError("Failed to load objective configuration") + middle_part = re.split( + r"\s+", configuration.rstrip(b"\x03").decode("utf-8") + )[1] + if middle_part == "0000": + self._objectives.append(None) + else: + part_number = "".join( + [weird_encoding[x] for x in bytes.fromhex(middle_part)] + ) + self._objectives.append( + part_number2objective.get(part_number.lower(), None) + ) + elif self.version.startswith("2"): + for slot in range(1, 7): + configuration = await self.send_command("i", f"h{slot + 1}") + assert configuration is not None + if configuration.startswith(b"****"): + self._objectives.append(None) + else: + annulus_code = int( + configuration.decode("latin").strip().split(" ")[0] + ) + annulus2objective = { + 1320520: Objective.O_4X_PL_FL_Phase, + 1320521: Objective.O_20X_PL_FL_Phase, + 1322026: Objective.O_40X_PL_FL_Phase, + } + self._objectives.append( + annulus2objective.get(annulus_code, None) + ) + else: + raise RuntimeError(f"Unsupported firmware version: {self.version}") + + logger.info("Loaded objectives: %s", self._objectives) + + # ─── Camera Control ────────────────────────────────────────────────── + + async def set_exposure(self, exposure: Exposure) -> None: + """Set camera exposure time in ms.""" + if exposure == "machine-auto": + await self.camera.set_auto_exposure("continuous") + else: + await self.camera.set_auto_exposure("off") + await self.camera.set_exposure(float(exposure)) + self._exposure = exposure + + async def set_gain(self, gain: Gain) -> None: + """Set camera gain.""" + if gain == "machine-auto": + pass + else: + await self.camera.set_gain(float(gain)) + self._gain = gain + + async def set_auto_exposure( + self, auto_exposure: Literal["off", "once", "continuous"] + ) -> None: + """Set camera auto-exposure mode.""" + await self.camera.set_auto_exposure(auto_exposure) + + def start_acquisition(self) -> None: + """Start camera acquisition (buffered streaming).""" + self.camera.start_acquisition() + self._acquiring = True + + def stop_acquisition(self) -> None: + """Stop camera acquisition.""" + if self._acquiring: + self.camera.stop_acquisition() + self._acquiring = False + + async def acquire_image(self) -> Image: + """Trigger camera and read image.""" + config = self.imaging_config + for attempt in range(config.max_image_read_attempts): + try: + image = await self.camera.trigger(timeout_ms=5000) + return image + except Exception: + if attempt < config.max_image_read_attempts - 1: + await asyncio.sleep(config.image_read_delay) + else: + raise + + async def get_exposure(self) -> float: + """Get current exposure time in ms.""" + return await self.camera.get_exposure() + + # ─── Optics Control (serial protocol) ──────────────────────────────── + + def _imaging_mode_code(self, mode: ImagingMode) -> int: + """Get filter wheel position index for an imaging mode. + + Brightfield and phase contrast use position 5 (no filter cube). + """ + if mode == ImagingMode.BRIGHTFIELD or mode == ImagingMode.PHASE_CONTRAST: + return 5 + for i, f in enumerate(self.filters): + if f == mode: + return i + 1 + raise ValueError(f"Mode {mode} not found in filters: {self.filters}") + + def _objective_code(self, objective: Objective) -> int: + """Get turret position index for an objective.""" + for i, o in enumerate(self.objectives): + if o == objective: + return i + 1 + raise ValueError(f"Objective {objective} not found: {self.objectives}") + + async def set_imaging_mode( + self, mode: ImagingMode, led_intensity: int = 10 + ) -> None: + """Set filter wheel position and LED. + + Brightfield uses filter position 5 (empty slot) and light path + mode 02 (transmitted). Fluorescence modes use the filter cube + position and light path mode 01 (epifluorescence). + """ + if mode == self._imaging_mode: + await self.led_on(intensity=led_intensity) + return + + if mode == ImagingMode.COLOR_BRIGHTFIELD: + raise NotImplementedError("Color brightfield not implemented") + + await self.led_off() + filter_index = self._imaging_mode_code(mode) + + if self.version.startswith("1"): + if mode == ImagingMode.PHASE_CONTRAST: + raise NotImplementedError("Phase contrast not implemented on Cytation 1") + elif mode == ImagingMode.BRIGHTFIELD: + await self.send_command("Y", "P0c05") + await self.send_command("Y", "P0f02") + else: + await self.send_command("Y", f"P0c{filter_index:02}") + await self.send_command("Y", "P0f01") + else: + await self.send_command("Y", f"P0c{filter_index:02}") + + self._imaging_mode = mode + await self.led_on(intensity=led_intensity) + await asyncio.sleep(0.5) + + async def set_objective(self, objective: Objective) -> None: + """Rotate objective turret to the specified objective.""" + if objective == self._objective: + return + obj_code = self._objective_code(objective) + if self.version.startswith("1"): + await self.send_command("Y", f"P0d{obj_code:02}", timeout=60) + else: + await self.send_command("Y", f"P0e{obj_code:02}", timeout=60) + self._objective = objective + self._imaging_mode = None # force re-set after objective change + + async def set_focus(self, focal_position: FocalPosition) -> None: + """Move focus motor to the specified height (mm). + + Uses the same linear calibration as CytationBackend. + """ + if focal_position == "machine-auto": + raise ValueError( + "focal_position cannot be 'machine-auto'. " + "Use PLR's Microscopy auto-focus instead." + ) + + if focal_position == self._focal_height: + return + + slope, intercept = (10.637991436186072, 1.0243013203461762) + focus_integer = int( + float(focal_position) + intercept + slope * float(focal_position) * 1000 + ) + focus_str = str(focus_integer).zfill(5) + + if self._imaging_mode is None: + raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") + imaging_mode_code = self._imaging_mode_code(self._imaging_mode) + await self.send_command("i", f"F{imaging_mode_code}0{focus_str}") + + self._focal_height = focal_position + + async def led_on(self, intensity: int = 10) -> None: + """Turn on LED at specified intensity (1–10).""" + if not 1 <= intensity <= 10: + raise ValueError("intensity must be between 1 and 10") + if self._imaging_mode is None: + raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") + imaging_mode_code = self._imaging_mode_code(self._imaging_mode) + intensity_str = str(intensity).zfill(2) + await self.send_command("i", f"L0{imaging_mode_code}{intensity_str}") + + async def led_off(self) -> None: + """Turn off LED.""" + await self.send_command("i", "L0001") + + async def select(self, row: int, column: int) -> None: + """Move plate stage to a well position.""" + if row == self._row and column == self._column: + return + row_str = str(row).zfill(2) + col_str = str(column).zfill(2) + await self.send_command("Y", f"W6{row_str}{col_str}") + self._row, self._column = row, column + self._pos_x, self._pos_y = None, None + await self.set_position(0, 0) + + async def set_position(self, x: float, y: float) -> None: + """Fine-position the plate stage within a well (mm).""" + if self._imaging_mode is None: + raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") + + if x == self._pos_x and y == self._pos_y: + return + + x_str = str(round(x * 100 * 0.984)).zfill(6) + y_str = str(round(y * 100 * 0.984)).zfill(6) + + if self._row is None or self._column is None: + raise ValueError("Row and column not set. Call select() first.") + row_str = str(self._row).zfill(2) + column_str = str(self._column).zfill(2) + + if self._objective is None: + raise ValueError("Objective not set. Call set_objective() first.") + objective_code = self._objective_code(self._objective) + imaging_mode_code = self._imaging_mode_code(self._imaging_mode) + await self.send_command( + "Y", + f"Z{objective_code}{imaging_mode_code}6{row_str}{column_str}" + f"{y_str}{x_str}", + ) + + relative_x = x - (self._pos_x or 0) + relative_y = y - (self._pos_y or 0) + if relative_x != 0: + relative_x_str = str(round(relative_x * 100 * 0.984)).zfill(6) + await self.send_command("Y", f"O00{relative_x_str}") + if relative_y != 0: + relative_y_str = str(round(relative_y * 100 * 0.984)).zfill(6) + await self.send_command("Y", f"O01{relative_y_str}") + + self._pos_x, self._pos_y = x, y diff --git a/pylabrobot/agilent/biotek/cytation_aravis_microscopy.py b/pylabrobot/agilent/biotek/cytation_aravis_microscopy.py new file mode 100644 index 00000000000..e832d0bebae --- /dev/null +++ b/pylabrobot/agilent/biotek/cytation_aravis_microscopy.py @@ -0,0 +1,170 @@ +"""CytationAravisMicroscopyBackend — MicroscopyBackend for the Cytation with Aravis. + +Orchestrates capture by sequencing the driver's optics and camera methods. +Same role as STARPIPBackend: translates capability operations into driver calls. + +Layer: Capability backend (orchestration) +Adjacent layers: + - Above: Microscopy capability calls capture() + - Below: CytationAravisDriver (optics + camera commands) +""" + +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Literal, Optional, Tuple, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.microscopy.backend import MicroscopyBackend +from pylabrobot.capabilities.microscopy.standard import ( + Exposure, + FocalPosition, + Gain, + Image, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.serializer import SerializableMixin + +if TYPE_CHECKING: + from .cytation_aravis_driver import CytationAravisDriver + +logger = logging.getLogger(__name__) + + +class CytationAravisMicroscopyBackend(MicroscopyBackend): + """MicroscopyBackend for the Cytation using Aravis camera. + + Orchestrates a capture by calling the driver's optics and camera methods + in the correct sequence. Same pattern as STARPIPBackend: the backend + translates capability operations into driver calls. + + Created by CytationAravisDriver during setup() and accessed via + ``driver.microscopy_backend``. + """ + + def __init__(self, driver: CytationAravisDriver) -> None: + self.driver = driver + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + # ─── Vendor Params ────────────────────────────────────────────────── + + @dataclass + class CaptureParams(BackendParams): + """Aravis-specific capture parameters. + + Passed via ``backend_params`` in ``Microscopy.capture()``. + """ + + led_intensity: int = 10 + coverage: Union[Literal["full"], Tuple[int, int]] = (1, 1) + center_position: Optional[Tuple[float, float]] = None + auto_stop_acquisition: bool = True + + # ─── MicroscopyBackend.capture() ───────────────────────────────────── + + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + backend_params: Optional[SerializableMixin] = None, + ) -> ImagingResult: + """Capture image(s) from a well. + + Orchestrates the full imaging pipeline via the driver: + 1. Set plate geometry (serial) + 2. Start camera acquisition + 3. Set objective (turret motor, serial) + 4. Set imaging mode / filter (filter wheel, serial) + 5. Select well (stage motor, serial) + 6. Set exposure and gain (AravisCamera) + 7. Set focal height (focus motor, serial) + 8. Trigger and grab image (AravisCamera) + 9. Return ImagingResult + """ + if not isinstance(backend_params, self.CaptureParams): + backend_params = CytationAravisMicroscopyBackend.CaptureParams() + + led_intensity = backend_params.led_intensity + coverage = backend_params.coverage + center_position = backend_params.center_position + auto_stop_acquisition = backend_params.auto_stop_acquisition + + d = self.driver + await d.set_plate(plate) + + if not d._acquiring: + d.start_acquisition() + + try: + await d.set_objective(objective) + await d.set_imaging_mode(mode, led_intensity=led_intensity) + await d.select(row, column) + await d.set_exposure(exposure_time) + await d.set_gain(gain) + await d.set_focus(focal_height) + + if center_position is not None: + await d.set_position(center_position[0], center_position[1]) + + images: List[Image] = [] + + if coverage == (1, 1): + image = await d.acquire_image() + images.append(image) + else: + if d._objective is None: + raise RuntimeError("Objective not set.") + magnification = d._objective.magnification + + fov_map = {4: 3.474, 20: 0.694, 40: 0.347} + fov = fov_map.get(int(magnification), 3.474) + + if coverage == "full": + first_well = plate.get_item(0) + well_w = first_well.get_size_x() + well_h = first_well.get_size_y() + rows_n = math.ceil(well_h / fov) + cols_n = math.ceil(well_w / fov) + else: + rows_n, cols_n = coverage + + cx = center_position[0] if center_position else 0.0 + cy = center_position[1] if center_position else 0.0 + + for yi in range(rows_n): + for xi in range(cols_n): + x_pos = (xi - (cols_n - 1) / 2) * fov + cx + y_pos = -(yi - (rows_n - 1) / 2) * fov + cy + await d.set_position(x=x_pos, y=y_pos) + image = await d.acquire_image() + images.append(image) + + finally: + await d.led_off() + if auto_stop_acquisition: + d.stop_acquisition() + + exposure_ms = await d.get_exposure() + focal_height_val = float(d._focal_height) if d._focal_height else 0.0 + + return ImagingResult( + images=images, + exposure_time=exposure_ms, + focal_height=focal_height_val, + ) From 86ed38bfdcdfcbe36eebc3e7409224c526d5f79b Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Fri, 3 Apr 2026 21:02:57 +0200 Subject: [PATCH 5/6] Fix linting and typo check: remove unused imports, fix gir false positive Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/agilent/biotek/aravis_camera.py | 4 ++-- pylabrobot/agilent/biotek/cytation.py | 12 +++--------- pylabrobot/agilent/biotek/cytation_aravis_driver.py | 5 +---- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/pylabrobot/agilent/biotek/aravis_camera.py b/pylabrobot/agilent/biotek/aravis_camera.py index 1d0b6fbde41..84428a718b2 100644 --- a/pylabrobot/agilent/biotek/aravis_camera.py +++ b/pylabrobot/agilent/biotek/aravis_camera.py @@ -139,7 +139,7 @@ async def setup(self, serial_number: str) -> None: raise ImportError( "Aravis is not installed. Install it with:\n" " macOS: brew install aravis && pip install PyGObject\n" - " Linux: sudo apt-get install libaravis-dev gir1.2-aravis-0.8 " + " Linux: sudo apt-get install libaravis-dev gobject-introspection " "&& pip install PyGObject\n" " Windows: pacman -S mingw-w64-x86_64-aravis (in MSYS2)" ) @@ -423,7 +423,7 @@ def enumerate_cameras() -> list[CameraInfo]: raise ImportError( "Aravis is not installed. Install it with:\n" " macOS: brew install aravis && pip install PyGObject\n" - " Linux: sudo apt-get install libaravis-dev gir1.2-aravis-0.8 " + " Linux: sudo apt-get install libaravis-dev gobject-introspection " "&& pip install PyGObject\n" " Windows: pacman -S mingw-w64-x86_64-aravis (in MSYS2)" ) diff --git a/pylabrobot/agilent/biotek/cytation.py b/pylabrobot/agilent/biotek/cytation.py index 1e44766628d..1514d6d48d4 100644 --- a/pylabrobot/agilent/biotek/cytation.py +++ b/pylabrobot/agilent/biotek/cytation.py @@ -10,7 +10,6 @@ from pylabrobot.agilent.biotek.biotek import BioTekBackend from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.microscopy import ( - Microscopy, MicroscopyBackend, ) from pylabrobot.capabilities.microscopy.standard import ( @@ -22,12 +21,7 @@ ImagingResult, Objective, ) -from pylabrobot.capabilities.plate_reading.absorbance import Absorbance -from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence -from pylabrobot.capabilities.plate_reading.luminescence import Luminescence -from pylabrobot.capabilities.temperature_controlling import TemperatureController -from pylabrobot.device import Device -from pylabrobot.resources import Coordinate, Plate, PlateHolder, Resource +from pylabrobot.resources import Plate from pylabrobot.serializer import SerializableMixin try: @@ -888,5 +882,5 @@ def image_size(magnification: float) -> Tuple[float, float]: # Backwards compatibility — device classes moved to cytation5.py / cytation1.py # --------------------------------------------------------------------------- -from .cytation1 import Cytation1 as Cytation1 # noqa: F401 -from .cytation5 import Cytation5 as Cytation5 # noqa: F401 +from .cytation1 import Cytation1 as Cytation1 # noqa: E402, F401 +from .cytation5 import Cytation5 as Cytation5 # noqa: E402, F401 diff --git a/pylabrobot/agilent/biotek/cytation_aravis_driver.py b/pylabrobot/agilent/biotek/cytation_aravis_driver.py index 28573b6f2e9..bdf918818db 100644 --- a/pylabrobot/agilent/biotek/cytation_aravis_driver.py +++ b/pylabrobot/agilent/biotek/cytation_aravis_driver.py @@ -19,11 +19,8 @@ import asyncio import logging import re -import time from dataclasses import dataclass -from typing import List, Literal, Optional, Tuple, Union - -import numpy as np +from typing import List, Literal, Optional from pylabrobot.capabilities.microscopy.standard import ( Exposure, From 5c3b41a5fcd52d80a942321b337e659bed64ffba Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 4 Apr 2026 16:52:01 -0700 Subject: [PATCH 6/6] Remove PySpin/Spinnaker dependency, use Aravis exclusively for camera access Replace all PySpin camera code in CytationBackend with AravisCamera calls. Simplify Cytation1/Cytation5 to always use CytationAravisDriver. Fix aravis_camera.py to accept serial_number=None (first available camera). Update docs notebook to use Microscopy.capture(well=...) API. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agilent/biotek/cytation/hello-world.ipynb | 213 +++- pylabrobot/agilent/__init__.py | 2 + pylabrobot/agilent/biotek/aravis_camera.py | 806 ++++++++------- pylabrobot/agilent/biotek/cytation.py | 355 +------ pylabrobot/agilent/biotek/cytation1.py | 47 +- pylabrobot/agilent/biotek/cytation5.py | 47 +- .../agilent/biotek/cytation_aravis_driver.py | 944 +++++++++--------- 7 files changed, 1095 insertions(+), 1319 deletions(-) diff --git a/docs/user_guide/agilent/biotek/cytation/hello-world.ipynb b/docs/user_guide/agilent/biotek/cytation/hello-world.ipynb index 81a82daa89f..d5e98ca73de 100644 --- a/docs/user_guide/agilent/biotek/cytation/hello-world.ipynb +++ b/docs/user_guide/agilent/biotek/cytation/hello-world.ipynb @@ -3,135 +3,258 @@ { "cell_type": "markdown", "id": "wez9bxdabm", - "source": "# Agilent BioTek Cytation\n\nThe Cytation is an Agilent BioTek multi-mode plate reader with optional microscopy imaging. Depending on the model it supports:\n\n- [Absorbance](../../../capabilities/absorbance)\n- [Fluorescence](../../../capabilities/fluorescence)\n- [Luminescence](../../../capabilities/luminescence)\n- [Microscopy](../../../capabilities/microscopy)\n- [Temperature control](../../../capabilities/temperature-control)\n\n| Model | PLR Name | Plate Reading | Microscopy | Temperature |\n|---|---|---|---|---|\n| Cytation 5 | `Cytation5` | Absorbance, Fluorescence, Luminescence | yes | yes |\n| Cytation 1 | `Cytation1` | -- | yes | yes |\n\nBoth models share the `CytationBackend` driver, which communicates over FTDI USB. The Cytation 5 adds plate-reading capabilities on top of the shared microscopy and temperature-control features.", - "metadata": {} + "metadata": {}, + "source": [ + "# Agilent BioTek Cytation\n", + "\n", + "The Cytation is an Agilent BioTek multi-mode plate reader with optional microscopy imaging. Depending on the model it supports:\n", + "\n", + "- [Absorbance](../../../capabilities/absorbance)\n", + "- [Fluorescence](../../../capabilities/fluorescence)\n", + "- [Luminescence](../../../capabilities/luminescence)\n", + "- [Microscopy](../../../capabilities/microscopy)\n", + "- [Temperature control](../../../capabilities/temperature-control)\n", + "\n", + "| Model | PLR Name | Plate Reading | Microscopy | Temperature |\n", + "|---|---|---|---|---|\n", + "| Cytation 5 | `Cytation5` | Absorbance, Fluorescence, Luminescence | yes | yes |\n", + "| Cytation 1 | `Cytation1` | -- | yes | yes |\n", + "\n", + "Both models share the `CytationBackend` driver, which communicates over FTDI USB. The Cytation 5 adds plate-reading capabilities on top of the shared microscopy and temperature-control features." + ] }, { "cell_type": "markdown", "id": "0rn94ubvq8dj", - "source": "## Setup\n\nThe examples below use a Cytation 5. For a Cytation 1, replace `Cytation5` with `Cytation1` (the Cytation 1 does not have `.absorbance`, `.fluorescence`, or `.luminescence` attributes).", - "metadata": {} + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "The examples below use a Cytation 5. For a Cytation 1, replace `Cytation5` with `Cytation1` (the Cytation 1 does not have `.absorbance`, `.fluorescence`, or `.luminescence` attributes)." + ] }, { "cell_type": "code", + "execution_count": null, "id": "ia4t5ga2ldg", - "source": "from pylabrobot.agilent.biotek.cytation import Cytation5\n\nc5 = Cytation5(name=\"cytation5\")\nawait c5.setup()", "metadata": {}, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.cytation import Cytation5\n", + "\n", + "c5 = Cytation5(name=\"cytation5\")\n", + "await c5.setup()" + ] }, { "cell_type": "markdown", "id": "35rmpdivj44", - "source": "Open and close the tray door. Pass `slow=True` for slower motor travel if needed.", - "metadata": {} + "metadata": {}, + "source": [ + "Open and close the tray door. Pass `slow=True` for slower motor travel if needed." + ] }, { "cell_type": "code", + "execution_count": null, "id": "l85qt1z6hdf", - "source": "await c5.open()\n\nfrom pylabrobot.resources import Cor_96_wellplate_360ul_Fb\nplate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\nc5.plate_holder.assign_child_resource(plate)\n\nawait c5.close()", "metadata": {}, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "await c5.open()\n", + "\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", + "c5.plate_holder.assign_child_resource(plate)\n", + "\n", + "await c5.close()" + ] }, { "cell_type": "markdown", "id": "hxkf2luxk9n", - "source": "## Plate reading (Cytation 5 only)\n\nThe Cytation 5 exposes `.absorbance`, `.fluorescence`, and `.luminescence` capability objects. For the full API, see [Absorbance](../../../capabilities/absorbance), [Fluorescence](../../../capabilities/fluorescence), and [Luminescence](../../../capabilities/luminescence).", - "metadata": {} + "metadata": {}, + "source": [ + "## Plate reading (Cytation 5 only)\n", + "\n", + "The Cytation 5 exposes `.absorbance`, `.fluorescence`, and `.luminescence` capability objects. For the full API, see [Absorbance](../../../capabilities/absorbance), [Fluorescence](../../../capabilities/fluorescence), and [Luminescence](../../../capabilities/luminescence)." + ] }, { "cell_type": "code", + "execution_count": null, "id": "hwipa2rwkzl", - "source": "# Absorbance\ndata = await c5.absorbance.read_absorbance(wavelength=450)", "metadata": {}, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "# Absorbance\n", + "data = await c5.absorbance.read_absorbance(wavelength=450)" + ] }, { "cell_type": "code", + "execution_count": null, "id": "jmvn8du2t5", - "source": "# Fluorescence\ndata = await c5.fluorescence.read_fluorescence(\n excitation_wavelength=485, emission_wavelength=528, focal_height=7.5\n)", "metadata": {}, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "# Fluorescence\n", + "data = await c5.fluorescence.read_fluorescence(\n", + " excitation_wavelength=485, emission_wavelength=528, focal_height=7.5\n", + ")" + ] }, { "cell_type": "code", + "execution_count": null, "id": "oxn123gjoh", - "source": "# Luminescence\ndata = await c5.luminescence.read_luminescence(focal_height=4.5)", "metadata": {}, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "# Luminescence\n", + "data = await c5.luminescence.read_luminescence(focal_height=4.5)" + ] }, { "cell_type": "markdown", "id": "1qn3t2pqvw", - "source": "## Microscopy\n\nBoth the Cytation 5 and Cytation 1 expose a `.microscopy` capability. For imaging, pass `use_cam=True` during setup so the Spinnaker camera is initialized. For the full API, see [Microscopy](../../../capabilities/microscopy).\n\nUse {class}`~pylabrobot.agilent.biotek.cytation.CytationBackend.CaptureParams` to control LED intensity, coverage tiling, and pixel format.", - "metadata": {} + "metadata": {}, + "source": [ + "## Microscopy\n", + "\n", + "Both the Cytation 5 and Cytation 1 expose a `.microscopy` capability. For imaging, pass `use_cam=True` during setup so the Spinnaker camera is initialized. For the full API, see [Microscopy](../../../capabilities/microscopy).\n", + "\n", + "Use {class}`~pylabrobot.agilent.biotek.cytation.CytationBackend.CaptureParams` to control LED intensity, coverage tiling, and pixel format." + ] }, { "cell_type": "code", + "execution_count": null, "id": "qr1jm6691a", - "source": "from pylabrobot.agilent.biotek.cytation import CytationBackend, CytationImagingConfig\nfrom pylabrobot.capabilities.microscopy.standard import ImagingMode, Objective\n\nres = await c5.microscopy.capture(\n row=1,\n column=2,\n mode=ImagingMode.BRIGHTFIELD,\n objective=Objective.O_4X_PL_FL_Phase,\n focal_height=0.833,\n exposure_time=5,\n gain=16,\n plate=plate,\n backend_params=CytationBackend.CaptureParams(led_intensity=10),\n)", "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.cytation import CytationBackend, CytationImagingConfig\n", + "from pylabrobot.capabilities.microscopy.standard import ImagingMode, Objective\n", + "\n", + "res = await c5.microscopy.capture(\n", + " well=(1, 2),\n", + " mode=ImagingMode.BRIGHTFIELD,\n", + " objective=Objective.O_4X_PL_FL_Phase,\n", + " focal_height=0.833,\n", + " exposure_time=5,\n", + " gain=16,\n", + " plate=plate,\n", + " backend_params=CytationBackend.CaptureParams(led_intensity=10),\n", + ")" + ] + }, + { + "cell_type": "code", "execution_count": null, - "outputs": [] + "id": "b7125e49", + "metadata": {}, + "outputs": [], + "source": [ + "from PIL import Image\n", + "img = Image.fromarray(res.images[0])\n", + "img" + ] }, { "cell_type": "markdown", "id": "km95iou30f", - "source": "Tile multiple fields of view with the `coverage` parameter:", - "metadata": {} + "metadata": {}, + "source": [ + "Tile multiple fields of view with the `coverage` parameter:" + ] }, { "cell_type": "code", + "execution_count": null, "id": "fi1o94l1uni", - "source": "res = await c5.microscopy.capture(\n row=1,\n column=2,\n mode=ImagingMode.BRIGHTFIELD,\n objective=Objective.O_4X_PL_FL_Phase,\n focal_height=0.833,\n exposure_time=5,\n gain=16,\n plate=plate,\n backend_params=CytationBackend.CaptureParams(\n led_intensity=10,\n coverage=(4, 4),\n ),\n)\nprint(f\"{len(res.images)} images captured\")", "metadata": {}, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "res = await c5.microscopy.capture(\n", + " well=(1, 2),\n", + " mode=ImagingMode.BRIGHTFIELD,\n", + " objective=Objective.O_4X_PL_FL_Phase,\n", + " focal_height=0.833,\n", + " exposure_time=5,\n", + " gain=16,\n", + " plate=plate,\n", + " backend_params=CytationBackend.CaptureParams(\n", + " led_intensity=10,\n", + " coverage=(4, 4),\n", + " ),\n", + ")\n", + "print(f\"{len(res.images)} images captured\")" + ] }, { "cell_type": "markdown", "id": "sa9pdeeo51", - "source": "## Temperature control\n\nBoth models expose a `.temperature` controller. For the full API, see [Temperature Control](../../../capabilities/temperature-control).", - "metadata": {} + "metadata": {}, + "source": [ + "## Temperature control\n", + "\n", + "Both models expose a `.temperature` controller. For the full API, see [Temperature Control](../../../capabilities/temperature-control)." + ] }, { "cell_type": "code", + "execution_count": null, "id": "qhsjnerhl3", - "source": "await c5.temperature.set_temperature(37.0)\n\ncurrent = await c5.temperature.request_temperature()\nprint(f\"{current:.1f} \\u00b0C\")\n\nawait c5.temperature.deactivate()", "metadata": {}, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "await c5.temperature.set_temperature(37.0)\n", + "\n", + "current = await c5.temperature.request_temperature()\n", + "print(f\"{current:.1f} \\u00b0C\")\n", + "\n", + "await c5.temperature.deactivate()" + ] }, { "cell_type": "markdown", "id": "f667qnt4occ", - "source": "## Teardown", - "metadata": {} + "metadata": {}, + "source": [ + "## Teardown" + ] }, { "cell_type": "code", + "execution_count": null, "id": "xcqz2zwu04g", - "source": "await c5.stop()", "metadata": {}, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "await c5.stop()" + ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "env", "language": "python", "name": "python3" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.11.0" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.15" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py index 0c5e113d9fb..38b73534c76 100644 --- a/pylabrobot/agilent/__init__.py +++ b/pylabrobot/agilent/__init__.py @@ -1,8 +1,10 @@ from .biotek import ( EL406, + AravisImagingConfig, BioTekBackend, Cytation1, Cytation5, + CytationAravisDriver, CytationBackend, CytationImagingConfig, EL406Driver, diff --git a/pylabrobot/agilent/biotek/aravis_camera.py b/pylabrobot/agilent/biotek/aravis_camera.py index 84428a718b2..26c29000f55 100644 --- a/pylabrobot/agilent/biotek/aravis_camera.py +++ b/pylabrobot/agilent/biotek/aravis_camera.py @@ -1,17 +1,11 @@ """Standalone BlackFly camera driver using Aravis (GenICam/USB3 Vision). Layer: Camera driver (standalone, no PLR dependencies except numpy) -Role: Replaces PySpin for image acquisition from FLIR/Teledyne BlackFly cameras. Adjacent layers: - - Above: CytationAravisBackend delegates camera operations here + - Above: CytationAravisDriver delegates camera operations here - Below: Aravis library (via PyGObject) talks to camera via USB3 Vision/GenICam -This module provides a pure-Aravis alternative to PySpin for controlling BlackFly -cameras. It eliminates the Spinnaker SDK dependency and the Python 3.10 version cap -that PySpin imposes. Aravis talks directly to the camera via the GenICam standard -over USB3 Vision — no vendor runtime needed. - -Architecture label: **[Proposed]** — Aravis as alternative to PySpin for PLR. +Aravis talks directly to the camera via the GenICam standard over USB3 Vision. """ from __future__ import annotations @@ -30,13 +24,15 @@ # - Python bindings: pip install PyGObject # If not installed, AravisCamera.setup() will raise ImportError with instructions. try: - import gi - gi.require_version("Aravis", "0.8") - from gi.repository import Aravis # type: ignore[attr-defined] - HAS_ARAVIS = True + import gi + + gi.require_version("Aravis", "0.8") + from gi.repository import Aravis # type: ignore[attr-defined] + + HAS_ARAVIS = True except (ImportError, ValueError): - HAS_ARAVIS = False - Aravis = None # type: ignore[assignment] + HAS_ARAVIS = False + Aravis = None # type: ignore[assignment] # Number of pre-allocated buffers for the Aravis stream. # For single-frame software-triggered capture, 5 is more than sufficient. @@ -45,412 +41,394 @@ @dataclass class CameraInfo: - """Discovery result for a GenICam-compatible camera. + """Discovery result for a GenICam-compatible camera. - Returned by AravisCamera.enumerate_cameras() and get_device_info(). - Contains identification and connection metadata — no hardware access needed - after discovery. - """ + Returned by AravisCamera.enumerate_cameras() and get_device_info(). + Contains identification and connection metadata — no hardware access needed + after discovery. + """ - serial_number: str - model_name: str - vendor: str - firmware_version: str - connection_type: str # "USB3" for the Cytation 5 BlackFly + serial_number: str + model_name: str + vendor: str + firmware_version: str + connection_type: str # "USB3" for the Cytation 5 BlackFly class AravisCamera: - """BlackFly camera driver using Aravis for GenICam access over USB3 Vision. - - This class wraps all Aravis/GenICam operations for single-frame image - acquisition with software triggering. It mirrors the camera methods that - PLR's CytationBackend calls on PySpin: - - PySpin → AravisCamera - _set_up_camera() → setup(serial_number) - _stop_camera() → stop() - start_acquisition() → start_acquisition() - stop_acquisition() → stop_acquisition() - set_exposure(ms) → set_exposure(ms) - set_gain(val) → set_gain(val) - set_auto_exposure(mode) → set_auto_exposure(mode) - _acquire_image() → trigger(timeout_ms) - - GenICam primer for non-camera-experts: - GenICam is a standard that defines how camera features (exposure, gain, - trigger, etc.) are named and accessed. Every compliant camera publishes - an XML file describing its features as "nodes." Aravis reads this XML - and lets you get/set node values by name (e.g., "ExposureTime", "Gain"). - This is the same mechanism PySpin uses internally — Aravis just provides - a different Python API to access it. - - Buffer management: - Aravis uses a producer-consumer model with pre-allocated buffers. - During setup(), we allocate a small pool of buffers and push them to - the stream. When we trigger a capture, Aravis fills a buffer with image - data. We pop the buffer, copy the data to a numpy array, and push the - buffer back to the pool for reuse. The copy is necessary because the - buffer memory is owned by Aravis and will be reused. - - Usage: - camera = AravisCamera() - await camera.setup("12345678") - await camera.set_exposure(10.0) # 10 ms - image = await camera.trigger() # numpy array, Mono8 - await camera.stop() + """BlackFly camera driver using Aravis for GenICam access over USB3 Vision. + + This class wraps all Aravis/GenICam operations for single-frame image + acquisition with software triggering. + + GenICam primer for non-camera-experts: + GenICam is a standard that defines how camera features (exposure, gain, + trigger, etc.) are named and accessed. Every compliant camera publishes + an XML file describing its features as "nodes." Aravis reads this XML + and lets you get/set node values by name (e.g., "ExposureTime", "Gain"). + Aravis provides a Python API to access these nodes via PyGObject. + + Buffer management: + Aravis uses a producer-consumer model with pre-allocated buffers. + During setup(), we allocate a small pool of buffers and push them to + the stream. When we trigger a capture, Aravis fills a buffer with image + data. We pop the buffer, copy the data to a numpy array, and push the + buffer back to the pool for reuse. The copy is necessary because the + buffer memory is owned by Aravis and will be reused. + + Usage: + camera = AravisCamera() + await camera.setup("12345678") + await camera.set_exposure(10.0) # 10 ms + image = await camera.trigger() # numpy array, Mono8 + await camera.stop() + """ + + def __init__(self) -> None: + self._camera: Optional[object] = None # Aravis.Camera + self._device: Optional[object] = None # Aravis.Device + self._stream: Optional[object] = None # Aravis.Stream + self._serial_number: Optional[str] = None + self._acquiring: bool = False + self._width: int = 0 + self._height: int = 0 + self._payload_size: int = 0 + + @property + def width(self) -> int: + """Image width in pixels (read-only, from camera default).""" + return self._width + + @property + def height(self) -> int: + """Image height in pixels (read-only, from camera default).""" + return self._height + + async def setup(self, serial_number: Optional[str] = None) -> None: + """Connect to camera, configure software trigger, allocate buffers. + + The software trigger configuration: + TriggerSelector=FrameStart, TriggerSource=Software, TriggerMode=On. + + Args: + serial_number: Camera serial number (e.g., "12345678"). If None, uses + the first available camera. Use enumerate_cameras() to discover + available cameras. + + Raises: + ImportError: If Aravis/PyGObject is not installed. + RuntimeError: If camera cannot be found or connected. """ + if not HAS_ARAVIS: + raise ImportError( + "Aravis is not installed. Install it with:\n" + " macOS: brew install aravis && pip install PyGObject\n" + " Linux: sudo apt-get install libaravis-dev gobject-introspection " + "&& pip install PyGObject\n" + " Windows: pacman -S mingw-w64-x86_64-aravis (in MSYS2)" + ) + + self._serial_number = serial_number + + Aravis.update_device_list() + n_devices = Aravis.get_n_devices() + + if n_devices == 0: + raise RuntimeError("No cameras found.") + + device_id_to_connect = None + if serial_number is None: + # Use the first available camera. + device_id_to_connect = Aravis.get_device_id(0) + else: + for i in range(n_devices): + dev_serial = Aravis.get_device_serial_nbr(i) or "" + dev_id = Aravis.get_device_id(i) or "" + if serial_number in (dev_serial, dev_id): + device_id_to_connect = dev_id + break + + if device_id_to_connect is None: + raise RuntimeError( + f"Camera with serial '{serial_number}' not found. " + f"Available devices: {[Aravis.get_device_id(i) for i in range(n_devices)]}" + ) + + try: + self._camera = Aravis.Camera.new(device_id_to_connect) + except Exception as e: + raise RuntimeError( + f"Failed to connect to camera '{device_id_to_connect}'. " + f"Is the camera in use by another process? Error: {e}" + ) from e + + self._device = self._camera.get_device() + + # Configure software trigger mode. + # GenICam nodes: TriggerSelector, TriggerSource, TriggerMode are + # standard SFNC (Standard Features Naming Convention) names. + self._device.set_string_feature_value("TriggerSelector", "FrameStart") + self._device.set_string_feature_value("TriggerSource", "Software") + self._device.set_string_feature_value("TriggerMode", "On") + + # Read image dimensions and payload size from camera. + self._width = self._camera.get_region()[2] # x, y, width, height + self._height = self._camera.get_region()[3] + self._payload_size = self._camera.get_payload() + + # BlackFly/Flea3 cameras need a delay after trigger mode change. + await asyncio.sleep(1) + + # Create stream and pre-allocate buffer pool. + # Aravis requires buffers to be pushed to the stream before acquisition. + # We allocate a small pool (5 buffers) — for single-frame software + # trigger, we only use one at a time but the pool prevents starvation. + self._stream = self._camera.create_stream(None, None) + for _ in range(_BUFFER_COUNT): + self._stream.push_buffer(Aravis.Buffer.new_allocate(self._payload_size)) + + logger.info( + "AravisCamera: Connected to %s (SN: %s), %dx%d", + self._device.get_string_feature_value("DeviceModelName"), + serial_number, + self._width, + self._height, + ) + + def start_acquisition(self) -> None: + """Begin camera acquisition (no-op if already acquiring). + + After this call, the + camera is ready to receive software triggers and produce image buffers. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + if self._acquiring: + return + self._camera.start_acquisition() + self._acquiring = True - def __init__(self) -> None: - self._camera: Optional[object] = None # Aravis.Camera - self._device: Optional[object] = None # Aravis.Device - self._stream: Optional[object] = None # Aravis.Stream - self._serial_number: Optional[str] = None - self._acquiring: bool = False - self._width: int = 0 - self._height: int = 0 - self._payload_size: int = 0 - - @property - def width(self) -> int: - """Image width in pixels (read-only, from camera default).""" - return self._width - - @property - def height(self) -> int: - """Image height in pixels (read-only, from camera default).""" - return self._height - - async def setup(self, serial_number: str) -> None: - """Connect to camera, configure software trigger, allocate buffers. - - This mirrors CytationBackend._set_up_camera() but uses Aravis instead - of PySpin. The software trigger configuration matches PLR's pattern: - TriggerSelector=FrameStart, TriggerSource=Software, TriggerMode=On. - - Args: - serial_number: Camera serial number (e.g., "12345678"). Use - enumerate_cameras() to discover available cameras. - - Raises: - ImportError: If Aravis/PyGObject is not installed. - RuntimeError: If camera cannot be found or connected. - """ - if not HAS_ARAVIS: - raise ImportError( - "Aravis is not installed. Install it with:\n" - " macOS: brew install aravis && pip install PyGObject\n" - " Linux: sudo apt-get install libaravis-dev gobject-introspection " - "&& pip install PyGObject\n" - " Windows: pacman -S mingw-w64-x86_64-aravis (in MSYS2)" - ) - - self._serial_number = serial_number - - # Connect to camera by serial number. - # Aravis.Camera.new() expects either the full device ID string or None - # (for first available). We search the device list to find the matching - # device ID for the given serial number. - Aravis.update_device_list() - device_id_to_connect = None - for i in range(Aravis.get_n_devices()): - dev_serial = Aravis.get_device_serial_nbr(i) or "" - dev_id = Aravis.get_device_id(i) or "" - if serial_number in (dev_serial, dev_id) or serial_number in dev_id: - device_id_to_connect = dev_id - break - - if device_id_to_connect is None: - raise RuntimeError( - f"Camera with serial '{serial_number}' not found. " - f"Available devices: {[Aravis.get_device_id(i) for i in range(Aravis.get_n_devices())]}" - ) + def stop_acquisition(self) -> None: + """End camera acquisition (no-op if not acquiring). + Stop camera acquisition. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + if not self._acquiring: + return + self._camera.stop_acquisition() + self._acquiring = False + + async def trigger(self, timeout_ms: int = 5000) -> np.ndarray: + """Capture a single frame: software trigger → grab. + + Acquisition must already be started via start_acquisition(). + Start/stop acquisition bracket the capture loop, not each individual frame. + + Args: + timeout_ms: Maximum time to wait for image buffer, in milliseconds. + Default 5000 (5 seconds). + + Returns: + numpy.ndarray: Image as 2D uint8 array (height × width), Mono8 format. + + Raises: + RuntimeError: If camera not initialized, not acquiring, or times out. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + if not self._acquiring: + raise RuntimeError("Camera is not acquiring. Call start_acquisition() first.") + + # Send software trigger command. + self._device.execute_command("TriggerSoftware") + + # Pop the filled buffer from the stream. + # timeout_pop_buffer takes microseconds, so convert from ms. + buffer = self._stream.timeout_pop_buffer(timeout_ms * 1000) + if buffer is None: + raise RuntimeError( + f"Camera capture timed out after {timeout_ms}ms. " + "Is the camera connected and trigger mode configured?" + ) + + # Extract image data and copy to numpy array. + data = buffer.get_data() + image = np.frombuffer(data, dtype=np.uint8).reshape(self._height, self._width).copy() + + # Return buffer to pool for reuse. + self._stream.push_buffer(buffer) + + return image + + async def stop(self) -> None: + """Release camera and free all Aravis resources. + + Safe to call at any point — + stops acquisition if active, resets trigger mode, releases camera. + """ + try: + if self._acquiring and self._camera is not None: + self.stop_acquisition() + + if self._device is not None: try: - self._camera = Aravis.Camera.new(device_id_to_connect) - except Exception as e: - raise RuntimeError( - f"Failed to connect to camera '{device_id_to_connect}'. " - f"Is the camera in use by another process? Error: {e}" - ) from e - - self._device = self._camera.get_device() - - # Configure software trigger mode. - # GenICam nodes: TriggerSelector, TriggerSource, TriggerMode are - # standard SFNC (Standard Features Naming Convention) names. - self._device.set_string_feature_value("TriggerSelector", "FrameStart") - self._device.set_string_feature_value("TriggerSource", "Software") - self._device.set_string_feature_value("TriggerMode", "On") - - # Read image dimensions and payload size from camera. - self._width = self._camera.get_region()[2] # x, y, width, height - self._height = self._camera.get_region()[3] - self._payload_size = self._camera.get_payload() - - # BlackFly/Flea3 cameras need a delay after trigger mode change. - # Same workaround as CytationBackend (PLR commit 226e6d41). - await asyncio.sleep(1) - - # Create stream and pre-allocate buffer pool. - # Aravis requires buffers to be pushed to the stream before acquisition. - # We allocate a small pool (5 buffers) — for single-frame software - # trigger, we only use one at a time but the pool prevents starvation. - self._stream = self._camera.create_stream(None, None) - for _ in range(_BUFFER_COUNT): - self._stream.push_buffer(Aravis.Buffer.new_allocate(self._payload_size)) - - logger.info( - "AravisCamera: Connected to %s (SN: %s), %dx%d", - self._device.get_string_feature_value("DeviceModelName"), - serial_number, - self._width, - self._height, - ) - - def start_acquisition(self) -> None: - """Begin camera acquisition (no-op if already acquiring). - - Mirrors CytationBackend.start_acquisition(). After this call, the - camera is ready to receive software triggers and produce image buffers. - """ - if self._camera is None: - raise RuntimeError("Camera is not initialized. Call setup() first.") - if self._acquiring: - return - self._camera.start_acquisition() - self._acquiring = True - - def stop_acquisition(self) -> None: - """End camera acquisition (no-op if not acquiring). - - Mirrors CytationBackend.stop_acquisition(). - """ - if self._camera is None: - raise RuntimeError("Camera is not initialized. Call setup() first.") - if not self._acquiring: - return - self._camera.stop_acquisition() - self._acquiring = False - - async def trigger(self, timeout_ms: int = 5000) -> np.ndarray: - """Capture a single frame: software trigger → grab. - - Acquisition must already be started via start_acquisition(). - This mirrors PySpin's pattern: start/stop acquisition bracket - the capture loop, not each individual frame. - - Args: - timeout_ms: Maximum time to wait for image buffer, in milliseconds. - Default 5000 (5 seconds). - - Returns: - numpy.ndarray: Image as 2D uint8 array (height × width), Mono8 format. - - Raises: - RuntimeError: If camera not initialized, not acquiring, or times out. - """ - if self._camera is None: - raise RuntimeError("Camera is not initialized. Call setup() first.") - if not self._acquiring: - raise RuntimeError( - "Camera is not acquiring. Call start_acquisition() first." - ) - - # Send software trigger command. - self._device.execute_command("TriggerSoftware") - - # Pop the filled buffer from the stream. - # timeout_pop_buffer takes microseconds, so convert from ms. - buffer = self._stream.timeout_pop_buffer(timeout_ms * 1000) - if buffer is None: - raise RuntimeError( - f"Camera capture timed out after {timeout_ms}ms. " - "Is the camera connected and trigger mode configured?" - ) - - # Extract image data and copy to numpy array. - data = buffer.get_data() - image = np.frombuffer(data, dtype=np.uint8).reshape( - self._height, self._width - ).copy() - - # Return buffer to pool for reuse. - self._stream.push_buffer(buffer) - - return image - - async def stop(self) -> None: - """Release camera and free all Aravis resources. - - Mirrors CytationBackend._stop_camera(). Safe to call at any point — - stops acquisition if active, resets trigger mode, releases camera. - """ - try: - if self._acquiring and self._camera is not None: - self.stop_acquisition() - - if self._device is not None: - try: - self._device.set_string_feature_value("TriggerMode", "Off") - except Exception: - pass # Camera may already be disconnected - finally: - self._camera = None - self._device = None - self._stream = None - self._serial_number = None - self._acquiring = False - self._width = 0 - self._height = 0 - self._payload_size = 0 - - async def set_exposure(self, exposure_ms: float) -> None: - """Set exposure time in milliseconds. - - Disables auto-exposure first, then sets the GenICam ExposureTime node. - PLR's CytationBackend uses milliseconds externally — GenICam uses - microseconds internally. This method handles the conversion. - - Args: - exposure_ms: Exposure time in milliseconds (e.g., 10.0 = 10 ms). - """ - if self._camera is None: - raise RuntimeError("Camera is not initialized. Call setup() first.") - # Disable auto-exposure before setting manual value. - self._device.set_string_feature_value("ExposureAuto", "Off") - # GenICam ExposureTime is in microseconds. - exposure_us = exposure_ms * 1000.0 - self._camera.set_exposure_time(exposure_us) - - async def get_exposure(self) -> float: - """Read current exposure time in milliseconds (from hardware, not cached).""" - if self._camera is None: - raise RuntimeError("Camera is not initialized. Call setup() first.") - exposure_us = self._camera.get_exposure_time() - return exposure_us / 1000.0 - - async def set_gain(self, gain: float) -> None: - """Set gain value. - - Disables auto-gain first, then sets the GenICam Gain node. - The gain range depends on the camera model (typically 0-30 for BlackFly). - - Args: - gain: Gain value (e.g., 1.0). - """ - if self._camera is None: - raise RuntimeError("Camera is not initialized. Call setup() first.") - self._device.set_string_feature_value("GainAuto", "Off") - self._camera.set_gain(gain) - - async def get_gain(self) -> float: - """Read current gain value (from hardware, not cached).""" - if self._camera is None: - raise RuntimeError("Camera is not initialized. Call setup() first.") - return self._camera.get_gain() - - async def set_auto_exposure(self, mode: str) -> None: - """Set auto-exposure mode. - - Args: - mode: One of "off", "once", "continuous". Maps to GenICam - ExposureAuto node values: Off, Once, Continuous. - """ - if self._camera is None: - raise RuntimeError("Camera is not initialized. Call setup() first.") - mode_map = {"off": "Off", "once": "Once", "continuous": "Continuous"} - aravis_mode = mode_map.get(mode.lower()) - if aravis_mode is None: - raise ValueError( - f"Invalid auto-exposure mode '{mode}'. Use 'off', 'once', or 'continuous'." - ) - self._device.set_string_feature_value("ExposureAuto", aravis_mode) - - async def set_pixel_format(self, fmt: Optional[int] = None) -> None: - """Set pixel format. Default is Mono8. - - Must be called before start_acquisition(). The format value is an - Aravis pixel format constant (e.g., Aravis.PIXEL_FORMAT_MONO_8). - - Args: - fmt: Aravis pixel format constant. If None, uses Mono8. - """ - if self._camera is None: - raise RuntimeError("Camera is not initialized. Call setup() first.") - if fmt is None: - if HAS_ARAVIS: - fmt = Aravis.PIXEL_FORMAT_MONO_8 - else: - return - self._camera.set_pixel_format(fmt) - - def get_device_info(self) -> CameraInfo: - """Read camera identification from GenICam nodes. - - Returns model name, serial number, vendor, and firmware version - without requiring acquisition to be active. - - Returns: - CameraInfo with fields populated from the camera's GenICam XML. - """ - if self._device is None: - raise RuntimeError("Camera is not initialized. Call setup() first.") - return CameraInfo( - serial_number=self._device.get_string_feature_value("DeviceSerialNumber"), - model_name=self._device.get_string_feature_value("DeviceModelName"), - vendor=self._device.get_string_feature_value("DeviceVendorName"), - firmware_version=self._device.get_string_feature_value( - "DeviceFirmwareVersion" - ), - connection_type="USB3", - ) - - @staticmethod - def enumerate_cameras() -> list[CameraInfo]: - """List all connected GenICam-compatible cameras. - - Uses Aravis device enumeration — finds cameras across USB3 Vision - and GigE Vision transports (though this driver targets USB3 only). - - Returns: - List of CameraInfo for each detected camera. Empty list if none - found (not an error — matches PLR's graceful enumeration pattern). - - Raises: - ImportError: If Aravis/PyGObject is not installed. - """ - if not HAS_ARAVIS: - raise ImportError( - "Aravis is not installed. Install it with:\n" - " macOS: brew install aravis && pip install PyGObject\n" - " Linux: sudo apt-get install libaravis-dev gobject-introspection " - "&& pip install PyGObject\n" - " Windows: pacman -S mingw-w64-x86_64-aravis (in MSYS2)" - ) - - Aravis.update_device_list() - n_devices = Aravis.get_n_devices() - cameras: list[CameraInfo] = [] - - for i in range(n_devices): - # Parse info from device ID string without opening the camera. - # Opening the camera here would lock the USB device and prevent - # a subsequent setup() from connecting (GObject doesn't release - # the USB handle reliably on del). - device_id = Aravis.get_device_id(i) - # device_id format varies: "USB3Vision-vendor-model-serial" or similar - serial = Aravis.get_device_serial_nbr(i) or device_id - model = Aravis.get_device_model(i) or "Unknown" - vendor = Aravis.get_device_vendor(i) or "Unknown" - protocol = Aravis.get_device_protocol(i) or "USB3" - - info = CameraInfo( - serial_number=serial, - model_name=model, - vendor=vendor, - firmware_version="", # Not available without opening camera - connection_type=protocol, - ) - cameras.append(info) - - return cameras + self._device.set_string_feature_value("TriggerMode", "Off") + except Exception: + pass # Camera may already be disconnected + finally: + self._camera = None + self._device = None + self._stream = None + self._serial_number = None + self._acquiring = False + self._width = 0 + self._height = 0 + self._payload_size = 0 + + async def set_exposure(self, exposure_ms: float) -> None: + """Set exposure time in milliseconds. + + Disables auto-exposure first, then sets the GenICam ExposureTime node. + GenICam uses microseconds internally; this method handles the conversion. + + Args: + exposure_ms: Exposure time in milliseconds (e.g., 10.0 = 10 ms). + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + # Disable auto-exposure before setting manual value. + self._device.set_string_feature_value("ExposureAuto", "Off") + # GenICam ExposureTime is in microseconds. + exposure_us = exposure_ms * 1000.0 + self._camera.set_exposure_time(exposure_us) + + async def get_exposure(self) -> float: + """Read current exposure time in milliseconds (from hardware, not cached).""" + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + exposure_us = self._camera.get_exposure_time() + return exposure_us / 1000.0 + + async def set_gain(self, gain: float) -> None: + """Set gain value. + + Disables auto-gain first, then sets the GenICam Gain node. + The gain range depends on the camera model (typically 0-30 for BlackFly). + + Args: + gain: Gain value (e.g., 1.0). + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + self._device.set_string_feature_value("GainAuto", "Off") + self._camera.set_gain(gain) + + async def get_gain(self) -> float: + """Read current gain value (from hardware, not cached).""" + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + return self._camera.get_gain() + + async def set_auto_exposure(self, mode: str) -> None: + """Set auto-exposure mode. + + Args: + mode: One of "off", "once", "continuous". Maps to GenICam + ExposureAuto node values: Off, Once, Continuous. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + mode_map = {"off": "Off", "once": "Once", "continuous": "Continuous"} + aravis_mode = mode_map.get(mode.lower()) + if aravis_mode is None: + raise ValueError(f"Invalid auto-exposure mode '{mode}'. Use 'off', 'once', or 'continuous'.") + self._device.set_string_feature_value("ExposureAuto", aravis_mode) + + async def set_pixel_format(self, fmt: Optional[int] = None) -> None: + """Set pixel format. Default is Mono8. + + Must be called before start_acquisition(). The format value is an + Aravis pixel format constant (e.g., Aravis.PIXEL_FORMAT_MONO_8). + + Args: + fmt: Aravis pixel format constant. If None, uses Mono8. + """ + if self._camera is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + if fmt is None: + if HAS_ARAVIS: + fmt = Aravis.PIXEL_FORMAT_MONO_8 + else: + return + self._camera.set_pixel_format(fmt) + + def get_device_info(self) -> CameraInfo: + """Read camera identification from GenICam nodes. + + Returns model name, serial number, vendor, and firmware version + without requiring acquisition to be active. + + Returns: + CameraInfo with fields populated from the camera's GenICam XML. + """ + if self._device is None: + raise RuntimeError("Camera is not initialized. Call setup() first.") + return CameraInfo( + serial_number=self._device.get_string_feature_value("DeviceSerialNumber"), + model_name=self._device.get_string_feature_value("DeviceModelName"), + vendor=self._device.get_string_feature_value("DeviceVendorName"), + firmware_version=self._device.get_string_feature_value("DeviceFirmwareVersion"), + connection_type="USB3", + ) + + @staticmethod + def enumerate_cameras() -> list[CameraInfo]: + """List all connected GenICam-compatible cameras. + + Uses Aravis device enumeration — finds cameras across USB3 Vision + and GigE Vision transports (though this driver targets USB3 only). + + Returns: + List of CameraInfo for each detected camera. Empty list if none + found (not an error — matches PLR's graceful enumeration pattern). + + Raises: + ImportError: If Aravis/PyGObject is not installed. + """ + if not HAS_ARAVIS: + raise ImportError( + "Aravis is not installed. Install it with:\n" + " macOS: brew install aravis && pip install PyGObject\n" + " Linux: sudo apt-get install libaravis-dev gobject-introspection " + "&& pip install PyGObject\n" + " Windows: pacman -S mingw-w64-x86_64-aravis (in MSYS2)" + ) + + Aravis.update_device_list() + n_devices = Aravis.get_n_devices() + cameras: list[CameraInfo] = [] + + for i in range(n_devices): + # Parse info from device ID string without opening the camera. + # Opening the camera here would lock the USB device and prevent + # a subsequent setup() from connecting (GObject doesn't release + # the USB handle reliably on del). + device_id = Aravis.get_device_id(i) + # device_id format varies: "USB3Vision-vendor-model-serial" or similar + serial = Aravis.get_device_serial_nbr(i) or device_id + model = Aravis.get_device_model(i) or "Unknown" + vendor = Aravis.get_device_vendor(i) or "Unknown" + protocol = Aravis.get_device_protocol(i) or "USB3" + + info = CameraInfo( + serial_number=serial, + model_name=model, + vendor=vendor, + firmware_version="", # Not available without opening camera + connection_type=protocol, + ) + cameras.append(info) + + return cameras diff --git a/pylabrobot/agilent/biotek/cytation.py b/pylabrobot/agilent/biotek/cytation.py index 1514d6d48d4..e0506678541 100644 --- a/pylabrobot/agilent/biotek/cytation.py +++ b/pylabrobot/agilent/biotek/cytation.py @@ -1,5 +1,4 @@ import asyncio -import atexit import logging import math import re @@ -24,19 +23,7 @@ from pylabrobot.resources import Plate from pylabrobot.serializer import SerializableMixin -try: - import PySpin # type: ignore - - USE_PYSPIN = True -except ImportError as e: - USE_PYSPIN = False - _PYSPIN_IMPORT_ERROR = e - -SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR = ( - PySpin.SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR if USE_PYSPIN else -1 -) -PixelFormat_Mono8 = PySpin.PixelFormat_Mono8 if USE_PYSPIN else -1 -SpinnakerException = PySpin.SpinnakerException if USE_PYSPIN else Exception +from .aravis_camera import AravisCamera logger = logging.getLogger(__name__) @@ -49,21 +36,6 @@ class CytationImagingConfig: filters: Optional[List[Optional[ImagingMode]]] = None -def retry(func, *args, **kwargs): - max_tries = 10 - delay = 0.1 - tries = 0 - while True: - try: - return func(*args, **kwargs) - except SpinnakerException as ex: - tries += 1 - if tries >= max_tries: - raise RuntimeError(f"Failed after {max_tries} tries") from ex - logger.warning("Retry %d/%d failed: %s", tries, max_tries, str(ex)) - time.sleep(delay) - - # --------------------------------------------------------------------------- # Backend # --------------------------------------------------------------------------- @@ -82,8 +54,7 @@ def __init__( timeout=timeout, device_id=device_id, human_readable_device_name="Agilent BioTek Cytation" ) - self._spinnaker_system: Optional["PySpin.SystemPtr"] = None - self._cam: Optional["PySpin.CameraPtr"] = None + self.camera = AravisCamera() self.imaging_config = imaging_config or CytationImagingConfig() self._filters: Optional[List[Optional[ImagingMode]]] = self.imaging_config.filters self._objectives: Optional[List[Optional[Objective]]] = self.imaging_config.objectives @@ -103,7 +74,7 @@ class SetupParams(BackendParams): """Cytation-specific parameters for ``setup``. Args: - use_cam: If True, initialize the Spinnaker camera during setup. + use_cam: If True, initialize the Aravis camera during setup. """ use_cam: bool = False @@ -117,27 +88,28 @@ async def setup(self, backend_params: Optional[BackendParams] = None) -> None: await super().setup() if backend_params.use_cam: - try: - await self._set_up_camera() - except: - try: - await self.stop() - except Exception: - pass - raise + serial = self.imaging_config.camera_serial_number if self.imaging_config else None + await self.camera.setup(serial_number=serial) + logger.info("Camera connected: %s", self.camera.get_device_info()) - async def stop(self): - await super().stop() + if self._filters is None: + await self._load_filters() + if self._objectives is None: + await self._load_objectives() + async def stop(self): if self._acquiring: self.stop_acquisition() + try: + await self.camera.stop() + except Exception: + logger.exception("Error stopping camera") + logger.info(f"{self.__class__.__name__} stopping") await self.stop_shaking() await self.io.stop() - self._stop_camera() - self._objectives = None self._filters = None self._slow_mode = None @@ -162,109 +134,6 @@ def supports_heating(self): def supports_cooling(self): return True - async def _set_up_camera(self) -> None: - atexit.register(self._stop_camera) - - if not USE_PYSPIN: - raise RuntimeError( - "PySpin is not installed. Please follow the imaging setup instructions. " - f"Import error: {_PYSPIN_IMPORT_ERROR}" - ) - if self.imaging_config is None: - raise RuntimeError("Imaging configuration is not set.") - - logger.debug(f"{self.__class__.__name__} setting up camera") - - self._spinnaker_system = PySpin.System.GetInstance() - version = self._spinnaker_system.GetLibraryVersion() - logger.debug( - f"{self.__class__.__name__} Library version: %d.%d.%d.%d", - version.major, - version.minor, - version.type, - version.build, - ) - - cam_list = self._spinnaker_system.GetCameras() - num_cameras = cam_list.GetSize() - logger.debug(f"{self.__class__.__name__} number of cameras detected: %d", num_cameras) - - for cam in cam_list: - info = self._get_device_info(cam) - serial_number = info["DeviceSerialNumber"] - logger.debug(f"{self.__class__.__name__} camera detected: %s", serial_number) - - if ( - self.imaging_config.camera_serial_number is not None - and serial_number == self.imaging_config.camera_serial_number - ): - self._cam = cam - logger.info(f"{self.__class__.__name__} using camera with serial number %s", serial_number) - break - else: - if num_cameras > 0: - self._cam = cam_list.GetByIndex(0) - logger.info( - f"{self.__class__.__name__} using first camera with serial number %s", - info["DeviceSerialNumber"], - ) - else: - logger.error(f"{self.__class__.__name__}: No cameras found") - self._cam = None - cam_list.Clear() - - if self._cam is None: - raise RuntimeError( - f"{self.__class__.__name__}: No camera found. Make sure the camera is connected and the " - "serial number is correct." - ) - - for _ in range(10): - try: - self._cam.Init() - break - except: # noqa - await asyncio.sleep(0.1) - else: - raise RuntimeError( - "Failed to initialize camera. Make sure the camera is connected and the " - "Spinnaker SDK is installed correctly." - ) - nodemap = self._cam.GetNodeMap() - - # Configure software trigger - ptr_trigger_selector = PySpin.CEnumerationPtr(nodemap.GetNode("TriggerSelector")) - if not PySpin.IsReadable(ptr_trigger_selector) or not PySpin.IsWritable(ptr_trigger_selector): - raise RuntimeError("unable to configure TriggerSelector") - ptr_frame_start = PySpin.CEnumEntryPtr(ptr_trigger_selector.GetEntryByName("FrameStart")) - if not PySpin.IsReadable(ptr_frame_start): - raise RuntimeError("unable to configure TriggerSelector (can't read FrameStart)") - ptr_trigger_selector.SetIntValue(int(ptr_frame_start.GetNumericValue())) - - ptr_trigger_source = PySpin.CEnumerationPtr(nodemap.GetNode("TriggerSource")) - if not PySpin.IsReadable(ptr_trigger_source) or not PySpin.IsWritable(ptr_trigger_source): - raise RuntimeError("unable to configure TriggerSource") - ptr_inference_ready = PySpin.CEnumEntryPtr(ptr_trigger_source.GetEntryByName("Software")) - if not PySpin.IsReadable(ptr_inference_ready): - raise RuntimeError("unable to configure TriggerSource (can't read Software)") - ptr_trigger_source.SetIntValue(int(ptr_inference_ready.GetNumericValue())) - - ptr_trigger_mode = PySpin.CEnumerationPtr(nodemap.GetNode("TriggerMode")) - if not PySpin.IsReadable(ptr_trigger_mode) or not PySpin.IsWritable(ptr_trigger_mode): - raise RuntimeError("unable to configure TriggerMode") - ptr_trigger_on = PySpin.CEnumEntryPtr(ptr_trigger_mode.GetEntryByName("On")) - if not PySpin.IsReadable(ptr_trigger_on): - raise RuntimeError("unable to query TriggerMode On") - ptr_trigger_mode.SetIntValue(int(ptr_trigger_on.GetNumericValue())) - - # Blackfly/Flea3 GEV cameras need 1 second delay after trigger mode on - await asyncio.sleep(1) - - if self._filters is None: - await self._load_filters() - if self._objectives is None: - await self._load_objectives() - @property def objectives(self) -> List[Optional[Objective]]: if self._objectives is None: @@ -411,70 +280,18 @@ async def _load_objectives(self): else: raise RuntimeError(f"{self.__class__.__name__}: Unsupported version: {self.version}") - def _stop_camera(self) -> None: - if self._cam is not None: - if self._acquiring: - self.stop_acquisition() - self._reset_trigger() - self._cam.DeInit() - self._cam = None - if self._spinnaker_system is not None: - self._spinnaker_system.ReleaseInstance() - - def _reset_trigger(self): - if self._cam is None: - return - try: - nodemap = self._cam.GetNodeMap() - node_trigger_mode = PySpin.CEnumerationPtr(nodemap.GetNode("TriggerMode")) - if not PySpin.IsReadable(node_trigger_mode) or not PySpin.IsWritable(node_trigger_mode): - return - node_trigger_mode_off = node_trigger_mode.GetEntryByName("Off") - if not PySpin.IsReadable(node_trigger_mode_off): - return - node_trigger_mode.SetIntValue(node_trigger_mode_off.GetValue()) - except PySpin.SpinnakerException: - pass - - def _get_device_info(self, cam): - device_info = {} - nodemap = cam.GetTLDeviceNodeMap() - node_device_information = PySpin.CCategoryPtr(nodemap.GetNode("DeviceInformation")) - if not PySpin.IsReadable(node_device_information): - raise RuntimeError("Device control information not readable.") - features = node_device_information.GetFeatures() - for feature in features: - node_feature = PySpin.CValuePtr(feature) - node_feature_name = node_feature.GetName() - try: - node_feature_value = node_feature.ToString() if PySpin.IsReadable(node_feature) else None - except Exception as e: - raise RuntimeError( - f"Got an error while reading feature {node_feature_name}. " - "Is the cytation in use by another notebook? " - f"Error: {str(e)}" - ) from e - device_info[node_feature_name] = node_feature_value - return device_info - async def close(self, plate: Optional[Plate] = None, slow: bool = False): await super().close(plate, slow) self._clear_imaging_state() def start_acquisition(self): - if self._cam is None: - raise RuntimeError(f"{self.__class__.__name__}: Camera is not initialized.") - if self._acquiring: - return - retry(self._cam.BeginAcquisition) + self.camera.start_acquisition() self._acquiring = True def stop_acquisition(self): - if self._cam is None: - raise RuntimeError(f"{self.__class__.__name__}: Camera is not initialized.") if not self._acquiring: return - retry(self._cam.EndAcquisition) + self.camera.stop_acquisition() self._acquiring = False async def led_on(self, intensity: int = 10): @@ -549,47 +366,21 @@ async def set_position(self, x: float, y: float): await asyncio.sleep(0.1) async def set_auto_exposure(self, auto_exposure: Literal["off", "once", "continuous"]): - if self._cam is None: - raise ValueError("Camera not initialized. Run setup(use_cam=True) first.") - - if self._cam.ExposureAuto.GetAccessMode() != PySpin.RW: - raise RuntimeError("unable to write ExposureAuto") - - retry( - self._cam.ExposureAuto.SetValue, - { - "off": PySpin.ExposureAuto_Off, - "once": PySpin.ExposureAuto_Once, - "continuous": PySpin.ExposureAuto_Continuous, - }[auto_exposure], - ) + await self.camera.set_auto_exposure(auto_exposure) async def set_exposure(self, exposure: Exposure): if exposure == self._exposure: logger.debug("Exposure time is already set to %s", exposure) return - if self._cam is None: - raise ValueError("Camera not initialized. Run setup(use_cam=True) first.") - if isinstance(exposure, str): if exposure == "machine-auto": await self.set_auto_exposure("continuous") self._exposure = "machine-auto" return raise ValueError("exposure must be a number or 'auto'") - retry(self._cam.ExposureAuto.SetValue, PySpin.ExposureAuto_Off) - - if self._cam.ExposureTime.GetAccessMode() != PySpin.RW: - raise RuntimeError("unable to write ExposureTime") - exposure_us = int(exposure * 1000) - min_et = retry(self._cam.ExposureTime.GetMin) - if exposure_us < min_et: - raise ValueError(f"exposure must be >= {min_et}") - max_et = retry(self._cam.ExposureTime.GetMax) - if exposure_us > max_et: - raise ValueError(f"exposure must be <= {max_et}") - retry(self._cam.ExposureTime.SetValue, exposure_us) + await self.camera.set_auto_exposure("off") + await self.camera.set_exposure(float(exposure)) self._exposure = exposure async def select(self, row: int, column: int): @@ -603,45 +394,12 @@ async def select(self, row: int, column: int): await self.set_position(0, 0) async def set_gain(self, gain: Gain): - if self._cam is None: - raise ValueError("Camera not initialized. Run setup(use_cam=True) first.") - if gain == self._gain: logger.debug("Gain is already set to %s", gain) return - if not (gain == "machine-auto" or 0 <= gain <= 30): - raise ValueError("gain must be between 0 and 30 (inclusive), or 'auto'") - - nodemap = self._cam.GetNodeMap() - - node_gain_auto = PySpin.CEnumerationPtr(nodemap.GetNode("GainAuto")) - if not PySpin.IsReadable(node_gain_auto) or not PySpin.IsWritable(node_gain_auto): - raise RuntimeError("unable to set automatic gain") - node = ( - PySpin.CEnumEntryPtr(node_gain_auto.GetEntryByName("Continuous")) - if gain == "machine-auto" - else PySpin.CEnumEntryPtr(node_gain_auto.GetEntryByName("Off")) - ) - if not PySpin.IsReadable(node): - raise RuntimeError("unable to set automatic gain (enum entry retrieval)") - node_gain_auto.SetIntValue(node.GetValue()) - - if not gain == "machine-auto": - node_gain = PySpin.CFloatPtr(nodemap.GetNode("Gain")) - if ( - not PySpin.IsReadable(node_gain) - or not PySpin.IsWritable(node_gain) - or node_gain.GetMax() == 0 - ): - raise RuntimeError("unable to set gain") - min_gain = node_gain.GetMin() - if gain < min_gain: - raise ValueError(f"gain must be >= {min_gain}") - max_gain = node_gain.GetMax() - if gain > max_gain: - raise ValueError(f"gain must be <= {max_gain}") - node_gain.SetValue(gain) + if gain != "machine-auto": + await self.camera.set_gain(float(gain)) self._gain = gain @@ -672,9 +430,6 @@ async def set_objective(self, objective: Objective): self._imaging_mode = None async def set_imaging_mode(self, mode: ImagingMode, led_intensity: int): - if self._cam is None: - raise ValueError("Camera not initialized. Run setup(use_cam=True) first.") - if mode == self._imaging_mode: logger.debug("Imaging mode is already set to %s", mode) await self.led_on(intensity=led_intensity) @@ -716,43 +471,19 @@ async def set_imaging_mode(self, mode: ImagingMode, led_intensity: int): self._imaging_mode = mode await self.led_on(intensity=led_intensity) - async def _acquire_image( - self, - color_processing_algorithm: int = SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR, - pixel_format: int = PixelFormat_Mono8, - ) -> Image: - assert self._cam is not None - nodemap = self._cam.GetNodeMap() + async def _acquire_image(self) -> Image: assert self.imaging_config is not None - - num_tries = 0 - while num_tries < self.imaging_config.max_image_read_attempts: - node_softwaretrigger_cmd = PySpin.CCommandPtr(nodemap.GetNode("TriggerSoftware")) - if not PySpin.IsWritable(node_softwaretrigger_cmd): - raise RuntimeError("unable to execute software trigger") - + for attempt in range(self.imaging_config.max_image_read_attempts): try: - node_softwaretrigger_cmd.Execute() - timeout = int(self._cam.ExposureTime.GetValue() / 1000 + 1000) - image_result = self._cam.GetNextImage(timeout) - if not image_result.IsIncomplete(): - processor = PySpin.ImageProcessor() - processor.SetColorProcessing(color_processing_algorithm) - image_converted = processor.Convert(image_result, pixel_format) - image_result.Release() - return image_converted.GetNDArray() # type: ignore - except SpinnakerException as e: - logger.warning("Failed to get image: %s", e) - self.stop_acquisition() - self.start_acquisition() - if "[-1011]" in str(e): - logger.warning( - "[-1011] error might occur when the camera is plugged into a USB hub " - "that does not have enough throughput." - ) - - num_tries += 1 - await asyncio.sleep(0.3) + return await self.camera.trigger(timeout_ms=5000) + except Exception: + if attempt < self.imaging_config.max_image_read_attempts - 1: + logger.warning("Failed to get image, retrying...") + self.stop_acquisition() + self.start_acquisition() + await asyncio.sleep(0.3) + else: + raise raise TimeoutError("max_image_read_attempts reached") @dataclass @@ -768,9 +499,6 @@ class CaptureParams(BackendParams): to the well center. If None, centers on the well. Default None. overlap: Fractional overlap between tiles (0.0-1.0) for montage stitching. If None, no overlap. Only used when coverage produces multiple tiles. - color_processing_algorithm: Spinnaker SDK color processing algorithm constant. - Default ``SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR``. - pixel_format: Spinnaker SDK pixel format constant. Default ``PixelFormat_Mono8``. auto_stop_acquisition: Whether to automatically stop image acquisition after capture. Default True. """ @@ -779,8 +507,6 @@ class CaptureParams(BackendParams): coverage: Union[Literal["full"], Tuple[int, int]] = (1, 1) center_position: Optional[Tuple[float, float]] = None overlap: Optional[float] = None - color_processing_algorithm: int = SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR - pixel_format: int = PixelFormat_Mono8 auto_stop_acquisition: bool = True async def capture( @@ -802,15 +528,10 @@ async def capture( coverage = backend_params.coverage center_position = backend_params.center_position overlap = backend_params.overlap - color_processing_algorithm = backend_params.color_processing_algorithm - pixel_format = backend_params.pixel_format auto_stop_acquisition = backend_params.auto_stop_acquisition assert overlap is None, "not implemented yet" - if self._cam is None: - raise ValueError("Camera not initialized. Run setup(use_cam=True) first.") - await self.set_plate(plate) if not self._acquiring: @@ -859,11 +580,7 @@ def image_size(magnification: float) -> Tuple[float, float]: for x_pos, y_pos in positions: await self.set_position(x=x_pos, y=y_pos) t0 = time.time() - images.append( - await self._acquire_image( - color_processing_algorithm=color_processing_algorithm, pixel_format=pixel_format - ) - ) + images.append(await self._acquire_image()) t1 = time.time() logger.debug("[cytation] acquired image in %.2f seconds", t1 - t0) finally: @@ -871,7 +588,7 @@ def image_size(magnification: float) -> Tuple[float, float]: if auto_stop_acquisition: self.stop_acquisition() - exposure_ms = float(self._cam.ExposureTime.GetValue()) / 1000 + exposure_ms = await self.camera.get_exposure() assert self._focal_height is not None focal_height_val = float(self._focal_height) diff --git a/pylabrobot/agilent/biotek/cytation1.py b/pylabrobot/agilent/biotek/cytation1.py index d5c47af0197..954ef214580 100644 --- a/pylabrobot/agilent/biotek/cytation1.py +++ b/pylabrobot/agilent/biotek/cytation1.py @@ -1,16 +1,12 @@ """Cytation 1 device — imager with temperature control. -Follows the STAR pattern: device creates driver internally based on -the ``camera`` parameter, setup() wires capabilities. +Follows the STAR pattern: device creates driver internally, +setup() wires capabilities. Example:: - # Aravis (default) cytation = Cytation1(name="cytation1", camera_serial="22580842") - # PySpin - cytation = Cytation1(name="cytation1", camera="pyspin") - await cytation.setup() result = await cytation.microscopy.capture(...) await cytation.stop() @@ -19,7 +15,7 @@ from __future__ import annotations import logging -from typing import Literal, Optional +from typing import Optional from pylabrobot.capabilities.microscopy import Microscopy from pylabrobot.capabilities.temperature_controlling import TemperatureController @@ -32,9 +28,7 @@ class Cytation1(Resource, Device): """Agilent BioTek Cytation 1 — imager with temperature control. - Creates the appropriate driver based on the ``camera`` parameter: - - ``"aravis"`` (default): CytationAravisDriver (Aravis/GenICam) - - ``"pyspin"``: CytationBackend (PySpin/Spinnaker SDK) + Uses CytationAravisDriver (Aravis/GenICam) for camera access. Capabilities: - microscopy (imaging) @@ -44,28 +38,18 @@ class Cytation1(Resource, Device): def __init__( self, name: str, - camera: Literal["aravis", "pyspin"] = "aravis", camera_serial: Optional[str] = None, device_id: Optional[str] = None, size_x: float = 0.0, size_y: float = 0.0, size_z: float = 0.0, ): - if camera == "aravis": - from .cytation_aravis_driver import CytationAravisDriver - driver = CytationAravisDriver( - camera_serial=camera_serial, - device_id=device_id, - ) - elif camera == "pyspin": - from .cytation import CytationBackend, CytationImagingConfig - config = CytationImagingConfig(camera_serial_number=camera_serial) - driver = CytationBackend( - device_id=device_id, - imaging_config=config, - ) - else: - raise ValueError(f"Unknown camera backend: {camera!r}. Use 'aravis' or 'pyspin'.") + from .cytation_aravis_driver import CytationAravisDriver + + driver = CytationAravisDriver( + camera_serial=camera_serial, + device_id=device_id, + ) Resource.__init__( self, @@ -77,7 +61,6 @@ def __init__( ) Device.__init__(self, driver=driver) self.driver = driver - self._camera = camera self.microscopy: Microscopy # set in setup() self.temperature: TemperatureController # set in setup() @@ -93,12 +76,8 @@ def __init__( self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) async def setup(self) -> None: - if self._camera == "aravis": - await self.driver.setup() - self.microscopy = Microscopy(backend=self.driver.microscopy_backend) - else: - await self.driver.setup(use_cam=True) - self.microscopy = Microscopy(backend=self.driver) + await self.driver.setup() + self.microscopy = Microscopy(backend=self.driver.microscopy_backend) self.temperature = TemperatureController(backend=self.driver) self._capabilities = [self.microscopy, self.temperature] @@ -106,7 +85,7 @@ async def setup(self) -> None: for cap in self._capabilities: await cap._on_setup() self._setup_finished = True - logger.info("Cytation1 setup complete (camera=%s)", self._camera) + logger.info("Cytation1 setup complete") async def stop(self) -> None: for cap in reversed(self._capabilities): diff --git a/pylabrobot/agilent/biotek/cytation5.py b/pylabrobot/agilent/biotek/cytation5.py index efca82448ac..e59c165b301 100644 --- a/pylabrobot/agilent/biotek/cytation5.py +++ b/pylabrobot/agilent/biotek/cytation5.py @@ -1,16 +1,12 @@ """Cytation 5 device — plate reader + imager. -Follows the STAR pattern: device creates driver internally based on -the ``camera`` parameter, setup() wires capabilities. +Follows the STAR pattern: device creates driver internally, +setup() wires capabilities. Example:: - # Aravis (default) cytation = Cytation5(name="cytation5", camera_serial="22580842") - # PySpin - cytation = Cytation5(name="cytation5", camera="pyspin") - await cytation.setup() result = await cytation.microscopy.capture(...) await cytation.stop() @@ -19,7 +15,7 @@ from __future__ import annotations import logging -from typing import Literal, Optional +from typing import Optional from pylabrobot.capabilities.microscopy import Microscopy from pylabrobot.capabilities.plate_reading.absorbance import Absorbance @@ -35,9 +31,7 @@ class Cytation5(Resource, Device): """Agilent BioTek Cytation 5 — plate reader + imager. - Creates the appropriate driver based on the ``camera`` parameter: - - ``"aravis"`` (default): CytationAravisDriver (Aravis/GenICam) - - ``"pyspin"``: CytationBackend (PySpin/Spinnaker SDK) + Uses CytationAravisDriver (Aravis/GenICam) for camera access. Capabilities: - absorbance, fluorescence, luminescence (plate reading) @@ -48,28 +42,18 @@ class Cytation5(Resource, Device): def __init__( self, name: str, - camera: Literal["aravis", "pyspin"] = "aravis", camera_serial: Optional[str] = None, device_id: Optional[str] = None, size_x: float = 0.0, size_y: float = 0.0, size_z: float = 0.0, ): - if camera == "aravis": - from .cytation_aravis_driver import CytationAravisDriver - driver = CytationAravisDriver( - camera_serial=camera_serial, - device_id=device_id, - ) - elif camera == "pyspin": - from .cytation import CytationBackend, CytationImagingConfig - config = CytationImagingConfig(camera_serial_number=camera_serial) - driver = CytationBackend( - device_id=device_id, - imaging_config=config, - ) - else: - raise ValueError(f"Unknown camera backend: {camera!r}. Use 'aravis' or 'pyspin'.") + from .cytation_aravis_driver import CytationAravisDriver + + driver = CytationAravisDriver( + camera_serial=camera_serial, + device_id=device_id, + ) Resource.__init__( self, @@ -81,7 +65,6 @@ def __init__( ) Device.__init__(self, driver=driver) self.driver = driver - self._camera = camera self.absorbance: Absorbance # set in setup() self.luminescence: Luminescence # set in setup() @@ -100,12 +83,8 @@ def __init__( self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) async def setup(self) -> None: - if self._camera == "aravis": - await self.driver.setup() - self.microscopy = Microscopy(backend=self.driver.microscopy_backend) - else: - await self.driver.setup(use_cam=True) - self.microscopy = Microscopy(backend=self.driver) + await self.driver.setup() + self.microscopy = Microscopy(backend=self.driver.microscopy_backend) # Plate reading + temperature use the driver directly # (BioTekBackend implements these backend ABCs) @@ -125,7 +104,7 @@ async def setup(self) -> None: for cap in self._capabilities: await cap._on_setup() self._setup_finished = True - logger.info("Cytation5 setup complete (camera=%s)", self._camera) + logger.info("Cytation5 setup complete") async def stop(self) -> None: for cap in reversed(self._capabilities): diff --git a/pylabrobot/agilent/biotek/cytation_aravis_driver.py b/pylabrobot/agilent/biotek/cytation_aravis_driver.py index bdf918818db..3174a49119f 100644 --- a/pylabrobot/agilent/biotek/cytation_aravis_driver.py +++ b/pylabrobot/agilent/biotek/cytation_aravis_driver.py @@ -5,9 +5,6 @@ The MicroscopyBackend (capture orchestration) is created during setup() and accessed via ``self.microscopy_backend``. -Follows the STARDriver pattern: the driver creates backends during setup(), -the device reads them to wire capabilities. - Layer: Driver (connection + low-level commands) Adjacent layers: - Above: Cytation1/Cytation5 device reads driver.microscopy_backend @@ -23,12 +20,12 @@ from typing import List, Literal, Optional from pylabrobot.capabilities.microscopy.standard import ( - Exposure, - FocalPosition, - Gain, - Image, - ImagingMode, - Objective, + Exposure, + FocalPosition, + Gain, + Image, + ImagingMode, + Objective, ) from .aravis_camera import AravisCamera @@ -40,477 +37,478 @@ @dataclass class AravisImagingConfig: - """Imaging configuration for the Cytation with Aravis camera. + """Imaging configuration for the Cytation with Aravis camera. - Defines filter wheel positions, objective positions, and camera serial. - """ + Defines filter wheel positions, objective positions, and camera serial. + """ - camera_serial_number: Optional[str] = None - filters: Optional[List[Optional[ImagingMode]]] = None - objectives: Optional[List[Optional[Objective]]] = None - max_image_read_attempts: int = 10 - image_read_delay: float = 0.3 + camera_serial_number: Optional[str] = None + filters: Optional[List[Optional[ImagingMode]]] = None + objectives: Optional[List[Optional[Objective]]] = None + max_image_read_attempts: int = 10 + image_read_delay: float = 0.3 class CytationAravisDriver(BioTekBackend): - """Driver for the Cytation using Aravis camera instead of PySpin. - - Extends BioTekBackend with: - - AravisCamera for image acquisition (replaces PySpin) - - Optics control methods (filter wheel, objectives, focus, LED, positioning) - - Camera control methods (exposure, gain, auto-exposure, trigger) - - During setup(), creates a CytationAravisMicroscopyBackend that the - device class reads to wire the Microscopy capability. - - Usage:: - - driver = CytationAravisDriver(camera_serial="22580842") - await driver.setup() - # driver.microscopy_backend is now available for Microscopy(backend=...) + """Driver for the Cytation using Aravis camera. + + Extends BioTekBackend with: + - AravisCamera for image acquisition (Aravis/GenICam over USB3 Vision) + - Optics control methods (filter wheel, objectives, focus, LED, positioning) + - Camera control methods (exposure, gain, auto-exposure, trigger) + + During setup(), creates a CytationAravisMicroscopyBackend that the + device class reads to wire the Microscopy capability. + + Usage:: + + driver = CytationAravisDriver(camera_serial="22580842") + await driver.setup() + # driver.microscopy_backend is now available for Microscopy(backend=...) + """ + + def __init__( + self, + camera_serial: Optional[str] = None, + timeout: float = 20, + device_id: Optional[str] = None, + imaging_config: Optional[AravisImagingConfig] = None, + ) -> None: + super().__init__( + timeout=timeout, + device_id=device_id, + human_readable_device_name="Agilent BioTek Cytation (Aravis)", + ) + + self.camera = AravisCamera() + self._camera_serial = camera_serial + self.imaging_config = imaging_config or AravisImagingConfig(camera_serial_number=camera_serial) + + # Imaging state + self._filters: Optional[List[Optional[ImagingMode]]] = self.imaging_config.filters + self._objectives: Optional[List[Optional[Objective]]] = self.imaging_config.objectives + self._exposure: Optional[Exposure] = None + self._focal_height: Optional[FocalPosition] = None + self._gain: Optional[Gain] = None + self._imaging_mode: Optional[ImagingMode] = None + self._row: Optional[int] = None + self._column: Optional[int] = None + self._pos_x: Optional[float] = None + self._pos_y: Optional[float] = None + self._objective: Optional[Objective] = None + self._acquiring = False + + # Created during setup() + self.microscopy_backend: CytationAravisMicroscopyBackend + + @property + def filters(self) -> List[Optional[ImagingMode]]: + if self._filters is None: + raise RuntimeError("Filters not loaded. Call setup() first.") + return self._filters + + @property + def objectives(self) -> List[Optional[Objective]]: + if self._objectives is None: + raise RuntimeError("Objectives not loaded. Call setup() first.") + return self._objectives + + # ─── Lifecycle ─────────────────────────────────────────────────────── + + async def setup(self, use_cam: bool = True) -> None: + """Set up serial connection and camera.""" + logger.info("CytationAravisDriver setting up") + + await super().setup() + + if use_cam: + serial = self._camera_serial or ( + self.imaging_config.camera_serial_number if self.imaging_config else None + ) + await self.camera.setup(serial_number=serial) + logger.info("Camera connected: %s", self.camera.get_device_info()) + + if self._filters is None: + await self._load_filters() + + if self._objectives is None: + await self._load_objectives() + + # Create microscopy backend (device reads this to wire Microscopy capability) + self.microscopy_backend = CytationAravisMicroscopyBackend(driver=self) + + logger.info("CytationAravisDriver setup complete") + + async def stop(self) -> None: + """Disconnect camera and serial.""" + self._clear_imaging_state() + try: + await self.camera.stop() + except Exception: + logger.exception("Error stopping camera") + await super().stop() + logger.info("CytationAravisDriver stopped") + + def _clear_imaging_state(self) -> None: + self._exposure = None + self._focal_height = None + self._gain = None + self._imaging_mode = None + self._row = None + self._column = None + self._pos_x = None + self._pos_y = None + self._objective = None + self._acquiring = False + + # ─── Filter / Objective Discovery ──────────────────────────────────── + + async def _load_filters(self) -> None: + """Discover installed filter cube positions from firmware. + + Queries each slot individually with command ``i q{slot}``. + Uses the Cytation firmware filter code mapping. """ - - def __init__( - self, - camera_serial: Optional[str] = None, - timeout: float = 20, - device_id: Optional[str] = None, - imaging_config: Optional[AravisImagingConfig] = None, - ) -> None: - super().__init__( - timeout=timeout, - device_id=device_id, - human_readable_device_name="Agilent BioTek Cytation (Aravis)", - ) - - self.camera = AravisCamera() - self._camera_serial = camera_serial - self.imaging_config = imaging_config or AravisImagingConfig( - camera_serial_number=camera_serial - ) - - # Imaging state - self._filters: Optional[List[Optional[ImagingMode]]] = ( - self.imaging_config.filters - ) - self._objectives: Optional[List[Optional[Objective]]] = ( - self.imaging_config.objectives - ) - self._exposure: Optional[Exposure] = None - self._focal_height: Optional[FocalPosition] = None - self._gain: Optional[Gain] = None - self._imaging_mode: Optional[ImagingMode] = None - self._row: Optional[int] = None - self._column: Optional[int] = None - self._pos_x: Optional[float] = None - self._pos_y: Optional[float] = None - self._objective: Optional[Objective] = None - self._acquiring = False - - # Created during setup() - self.microscopy_backend: CytationAravisMicroscopyBackend - - @property - def filters(self) -> List[Optional[ImagingMode]]: - if self._filters is None: - raise RuntimeError("Filters not loaded. Call setup() first.") - return self._filters - - @property - def objectives(self) -> List[Optional[Objective]]: - if self._objectives is None: - raise RuntimeError("Objectives not loaded. Call setup() first.") - return self._objectives - - # ─── Lifecycle ─────────────────────────────────────────────────────── - - async def setup(self, use_cam: bool = True) -> None: - """Set up serial connection and camera.""" - logger.info("CytationAravisDriver setting up") - - await super().setup() - - if use_cam: - serial = ( - self._camera_serial - or (self.imaging_config.camera_serial_number if self.imaging_config else None) - ) - await self.camera.setup(serial_number=serial) - logger.info("Camera connected: %s", self.camera.get_device_info()) - - if self._filters is None: - await self._load_filters() - - if self._objectives is None: - await self._load_objectives() - - # Create microscopy backend (device reads this to wire Microscopy capability) - self.microscopy_backend = CytationAravisMicroscopyBackend(driver=self) - - logger.info("CytationAravisDriver setup complete") - - async def stop(self) -> None: - """Disconnect camera and serial.""" - self._clear_imaging_state() - try: - await self.camera.stop() - except Exception: - logger.exception("Error stopping camera") - await super().stop() - logger.info("CytationAravisDriver stopped") - - def _clear_imaging_state(self) -> None: - self._exposure = None - self._focal_height = None - self._gain = None - self._imaging_mode = None - self._row = None - self._column = None - self._pos_x = None - self._pos_y = None - self._objective = None - self._acquiring = False - - # ─── Filter / Objective Discovery ──────────────────────────────────── - - async def _load_filters(self) -> None: - """Discover installed filter cube positions from firmware. - - Queries each slot individually with command ``i q{slot}``. - Uses the same code mapping as CytationBackend (cytation.py). - """ - cytation_code2imaging_mode = { - 1225121: ImagingMode.C377_647, - 1225123: ImagingMode.C400_647, - 1225113: ImagingMode.C469_593, - 1225109: ImagingMode.ACRIDINE_ORANGE, - 1225107: ImagingMode.CFP, - 1225118: ImagingMode.CFP_FRET_V2, - 1225110: ImagingMode.CFP_YFP_FRET, - 1225119: ImagingMode.CFP_YFP_FRET_V2, - 1225112: ImagingMode.CHLOROPHYLL_A, - 1225105: ImagingMode.CY5, - 1225114: ImagingMode.CY5_5, - 1225106: ImagingMode.CY7, - 1225100: ImagingMode.DAPI, - 1225101: ImagingMode.GFP, - 1225116: ImagingMode.GFP_CY5, - 1225122: ImagingMode.OXIDIZED_ROGFP2, - 1225111: ImagingMode.PROPIDIUM_IODIDE, - 1225103: ImagingMode.RFP, - 1225117: ImagingMode.RFP_CY5, - 1225115: ImagingMode.TAG_BFP, - 1225102: ImagingMode.TEXAS_RED, - 1225104: ImagingMode.YFP, - } - - self._filters = [] - for slot in range(1, 5): - configuration = await self.send_command("i", f"q{slot}") - assert configuration is not None - parts = configuration.decode().strip().split(" ") - if len(parts) == 1: - self._filters.append(None) - else: - cytation_code = int(parts[0]) - self._filters.append( - cytation_code2imaging_mode.get(cytation_code, None) - ) - - logger.info("Loaded filters: %s", self._filters) - - async def _load_objectives(self) -> None: - """Discover installed objective positions from firmware. - - Queries each slot individually. Uses the same weird encoding - and part number mapping as CytationBackend (cytation.py). - Firmware version 1.x uses ``i o{slot}``, version 2.x uses ``i h{slot}``. - """ - weird_encoding = { - 0x00: " ", 0x14: ".", 0x15: "/", - 0x16: "0", 0x17: "1", 0x18: "2", 0x19: "3", - 0x20: "4", 0x21: "5", 0x22: "6", 0x23: "7", - 0x24: "8", 0x25: "9", - 0x33: "A", 0x34: "B", 0x35: "C", 0x36: "D", - 0x37: "E", 0x38: "F", 0x39: "G", - 0x40: "H", 0x41: "I", 0x42: "J", 0x43: "K", - 0x44: "L", 0x45: "M", 0x46: "N", 0x47: "O", - 0x48: "P", 0x49: "Q", 0x50: "R", 0x51: "S", - 0x52: "T", 0x53: "U", 0x54: "V", 0x55: "W", - 0x56: "X", 0x57: "Y", 0x58: "Z", - } - part_number2objective = { - "uplsapo 40x2": Objective.O_40X_PL_APO, - "lucplfln 60X": Objective.O_60X_PL_FL, - "uplfln 4x": Objective.O_4X_PL_FL, - "lucplfln 20xph": Objective.O_20X_PL_FL_Phase, - "lucplfln 40xph": Objective.O_40X_PL_FL_Phase, - "u plan": Objective.O_2_5X_PL_ACH_Meiji, - "uplfln 10xph": Objective.O_10X_PL_FL_Phase, - "plapon 1.25x": Objective.O_1_25X_PL_APO, - "uplfln 10x": Objective.O_10X_PL_FL, - "uplfln 60xoi": Objective.O_60X_OIL_PL_FL, - "pln 4x": Objective.O_4X_PL_ACH, - "pln 40x": Objective.O_40X_PL_ACH, - "lucplfln 40x": Objective.O_40X_PL_FL, - "ec-h-plan/2x": Objective.O_2X_PL_ACH_Motic, - "uplfln 100xO2": Objective.O_100X_OIL_PL_FL, - "uplfln 4xph": Objective.O_4X_PL_FL_Phase, - "lucplfln 20X": Objective.O_20X_PL_FL, - "pln 20x": Objective.O_20X_PL_ACH, - "fluar 2.5x/0.12": Objective.O_2_5X_FL_Zeiss, - "uplsapo 100xo": Objective.O_100X_OIL_PL_APO, - "plapon 60xo": Objective.O_60X_OIL_PL_APO, - "uplsapo 20x": Objective.O_20X_PL_APO, - } - - self._objectives = [] - if self.version.startswith("1"): - for slot in [1, 2]: - configuration = await self.send_command("i", f"o{slot}") - if configuration is None: - raise RuntimeError("Failed to load objective configuration") - middle_part = re.split( - r"\s+", configuration.rstrip(b"\x03").decode("utf-8") - )[1] - if middle_part == "0000": - self._objectives.append(None) - else: - part_number = "".join( - [weird_encoding[x] for x in bytes.fromhex(middle_part)] - ) - self._objectives.append( - part_number2objective.get(part_number.lower(), None) - ) - elif self.version.startswith("2"): - for slot in range(1, 7): - configuration = await self.send_command("i", f"h{slot + 1}") - assert configuration is not None - if configuration.startswith(b"****"): - self._objectives.append(None) - else: - annulus_code = int( - configuration.decode("latin").strip().split(" ")[0] - ) - annulus2objective = { - 1320520: Objective.O_4X_PL_FL_Phase, - 1320521: Objective.O_20X_PL_FL_Phase, - 1322026: Objective.O_40X_PL_FL_Phase, - } - self._objectives.append( - annulus2objective.get(annulus_code, None) - ) + cytation_code2imaging_mode = { + 1225121: ImagingMode.C377_647, + 1225123: ImagingMode.C400_647, + 1225113: ImagingMode.C469_593, + 1225109: ImagingMode.ACRIDINE_ORANGE, + 1225107: ImagingMode.CFP, + 1225118: ImagingMode.CFP_FRET_V2, + 1225110: ImagingMode.CFP_YFP_FRET, + 1225119: ImagingMode.CFP_YFP_FRET_V2, + 1225112: ImagingMode.CHLOROPHYLL_A, + 1225105: ImagingMode.CY5, + 1225114: ImagingMode.CY5_5, + 1225106: ImagingMode.CY7, + 1225100: ImagingMode.DAPI, + 1225101: ImagingMode.GFP, + 1225116: ImagingMode.GFP_CY5, + 1225122: ImagingMode.OXIDIZED_ROGFP2, + 1225111: ImagingMode.PROPIDIUM_IODIDE, + 1225103: ImagingMode.RFP, + 1225117: ImagingMode.RFP_CY5, + 1225115: ImagingMode.TAG_BFP, + 1225102: ImagingMode.TEXAS_RED, + 1225104: ImagingMode.YFP, + } + + self._filters = [] + for slot in range(1, 5): + configuration = await self.send_command("i", f"q{slot}") + assert configuration is not None + parts = configuration.decode().strip().split(" ") + if len(parts) == 1: + self._filters.append(None) + else: + cytation_code = int(parts[0]) + self._filters.append(cytation_code2imaging_mode.get(cytation_code, None)) + + logger.info("Loaded filters: %s", self._filters) + + async def _load_objectives(self) -> None: + """Discover installed objective positions from firmware. + + Queries each slot individually. Uses the same weird encoding + and part number mapping. + Firmware version 1.x uses ``i o{slot}``, version 2.x uses ``i h{slot}``. + """ + weird_encoding = { + 0x00: " ", + 0x14: ".", + 0x15: "/", + 0x16: "0", + 0x17: "1", + 0x18: "2", + 0x19: "3", + 0x20: "4", + 0x21: "5", + 0x22: "6", + 0x23: "7", + 0x24: "8", + 0x25: "9", + 0x33: "A", + 0x34: "B", + 0x35: "C", + 0x36: "D", + 0x37: "E", + 0x38: "F", + 0x39: "G", + 0x40: "H", + 0x41: "I", + 0x42: "J", + 0x43: "K", + 0x44: "L", + 0x45: "M", + 0x46: "N", + 0x47: "O", + 0x48: "P", + 0x49: "Q", + 0x50: "R", + 0x51: "S", + 0x52: "T", + 0x53: "U", + 0x54: "V", + 0x55: "W", + 0x56: "X", + 0x57: "Y", + 0x58: "Z", + } + part_number2objective = { + "uplsapo 40x2": Objective.O_40X_PL_APO, + "lucplfln 60X": Objective.O_60X_PL_FL, + "uplfln 4x": Objective.O_4X_PL_FL, + "lucplfln 20xph": Objective.O_20X_PL_FL_Phase, + "lucplfln 40xph": Objective.O_40X_PL_FL_Phase, + "u plan": Objective.O_2_5X_PL_ACH_Meiji, + "uplfln 10xph": Objective.O_10X_PL_FL_Phase, + "plapon 1.25x": Objective.O_1_25X_PL_APO, + "uplfln 10x": Objective.O_10X_PL_FL, + "uplfln 60xoi": Objective.O_60X_OIL_PL_FL, + "pln 4x": Objective.O_4X_PL_ACH, + "pln 40x": Objective.O_40X_PL_ACH, + "lucplfln 40x": Objective.O_40X_PL_FL, + "ec-h-plan/2x": Objective.O_2X_PL_ACH_Motic, + "uplfln 100xO2": Objective.O_100X_OIL_PL_FL, + "uplfln 4xph": Objective.O_4X_PL_FL_Phase, + "lucplfln 20X": Objective.O_20X_PL_FL, + "pln 20x": Objective.O_20X_PL_ACH, + "fluar 2.5x/0.12": Objective.O_2_5X_FL_Zeiss, + "uplsapo 100xo": Objective.O_100X_OIL_PL_APO, + "plapon 60xo": Objective.O_60X_OIL_PL_APO, + "uplsapo 20x": Objective.O_20X_PL_APO, + } + + self._objectives = [] + if self.version.startswith("1"): + for slot in [1, 2]: + configuration = await self.send_command("i", f"o{slot}") + if configuration is None: + raise RuntimeError("Failed to load objective configuration") + middle_part = re.split(r"\s+", configuration.rstrip(b"\x03").decode("utf-8"))[1] + if middle_part == "0000": + self._objectives.append(None) + else: + part_number = "".join([weird_encoding[x] for x in bytes.fromhex(middle_part)]) + self._objectives.append(part_number2objective.get(part_number.lower(), None)) + elif self.version.startswith("2"): + for slot in range(1, 7): + configuration = await self.send_command("i", f"h{slot + 1}") + assert configuration is not None + if configuration.startswith(b"****"): + self._objectives.append(None) else: - raise RuntimeError(f"Unsupported firmware version: {self.version}") + annulus_code = int(configuration.decode("latin").strip().split(" ")[0]) + annulus2objective = { + 1320520: Objective.O_4X_PL_FL_Phase, + 1320521: Objective.O_20X_PL_FL_Phase, + 1322026: Objective.O_40X_PL_FL_Phase, + } + self._objectives.append(annulus2objective.get(annulus_code, None)) + else: + raise RuntimeError(f"Unsupported firmware version: {self.version}") + + logger.info("Loaded objectives: %s", self._objectives) + + # ─── Camera Control ────────────────────────────────────────────────── + + async def set_exposure(self, exposure: Exposure) -> None: + """Set camera exposure time in ms.""" + if exposure == "machine-auto": + await self.camera.set_auto_exposure("continuous") + else: + await self.camera.set_auto_exposure("off") + await self.camera.set_exposure(float(exposure)) + self._exposure = exposure + + async def set_gain(self, gain: Gain) -> None: + """Set camera gain.""" + if gain == "machine-auto": + pass + else: + await self.camera.set_gain(float(gain)) + self._gain = gain + + async def set_auto_exposure(self, auto_exposure: Literal["off", "once", "continuous"]) -> None: + """Set camera auto-exposure mode.""" + await self.camera.set_auto_exposure(auto_exposure) + + def start_acquisition(self) -> None: + """Start camera acquisition (buffered streaming).""" + self.camera.start_acquisition() + self._acquiring = True + + def stop_acquisition(self) -> None: + """Stop camera acquisition.""" + if self._acquiring: + self.camera.stop_acquisition() + self._acquiring = False + + async def acquire_image(self) -> Image: + """Trigger camera and read image.""" + config = self.imaging_config + for attempt in range(config.max_image_read_attempts): + try: + image = await self.camera.trigger(timeout_ms=5000) + return image + except Exception: + if attempt < config.max_image_read_attempts - 1: + await asyncio.sleep(config.image_read_delay) + else: + raise - logger.info("Loaded objectives: %s", self._objectives) + async def get_exposure(self) -> float: + """Get current exposure time in ms.""" + return await self.camera.get_exposure() - # ─── Camera Control ────────────────────────────────────────────────── + # ─── Optics Control (serial protocol) ──────────────────────────────── - async def set_exposure(self, exposure: Exposure) -> None: - """Set camera exposure time in ms.""" - if exposure == "machine-auto": - await self.camera.set_auto_exposure("continuous") - else: - await self.camera.set_auto_exposure("off") - await self.camera.set_exposure(float(exposure)) - self._exposure = exposure - - async def set_gain(self, gain: Gain) -> None: - """Set camera gain.""" - if gain == "machine-auto": - pass - else: - await self.camera.set_gain(float(gain)) - self._gain = gain - - async def set_auto_exposure( - self, auto_exposure: Literal["off", "once", "continuous"] - ) -> None: - """Set camera auto-exposure mode.""" - await self.camera.set_auto_exposure(auto_exposure) - - def start_acquisition(self) -> None: - """Start camera acquisition (buffered streaming).""" - self.camera.start_acquisition() - self._acquiring = True - - def stop_acquisition(self) -> None: - """Stop camera acquisition.""" - if self._acquiring: - self.camera.stop_acquisition() - self._acquiring = False - - async def acquire_image(self) -> Image: - """Trigger camera and read image.""" - config = self.imaging_config - for attempt in range(config.max_image_read_attempts): - try: - image = await self.camera.trigger(timeout_ms=5000) - return image - except Exception: - if attempt < config.max_image_read_attempts - 1: - await asyncio.sleep(config.image_read_delay) - else: - raise - - async def get_exposure(self) -> float: - """Get current exposure time in ms.""" - return await self.camera.get_exposure() - - # ─── Optics Control (serial protocol) ──────────────────────────────── - - def _imaging_mode_code(self, mode: ImagingMode) -> int: - """Get filter wheel position index for an imaging mode. - - Brightfield and phase contrast use position 5 (no filter cube). - """ - if mode == ImagingMode.BRIGHTFIELD or mode == ImagingMode.PHASE_CONTRAST: - return 5 - for i, f in enumerate(self.filters): - if f == mode: - return i + 1 - raise ValueError(f"Mode {mode} not found in filters: {self.filters}") - - def _objective_code(self, objective: Objective) -> int: - """Get turret position index for an objective.""" - for i, o in enumerate(self.objectives): - if o == objective: - return i + 1 - raise ValueError(f"Objective {objective} not found: {self.objectives}") - - async def set_imaging_mode( - self, mode: ImagingMode, led_intensity: int = 10 - ) -> None: - """Set filter wheel position and LED. - - Brightfield uses filter position 5 (empty slot) and light path - mode 02 (transmitted). Fluorescence modes use the filter cube - position and light path mode 01 (epifluorescence). - """ - if mode == self._imaging_mode: - await self.led_on(intensity=led_intensity) - return - - if mode == ImagingMode.COLOR_BRIGHTFIELD: - raise NotImplementedError("Color brightfield not implemented") - - await self.led_off() - filter_index = self._imaging_mode_code(mode) - - if self.version.startswith("1"): - if mode == ImagingMode.PHASE_CONTRAST: - raise NotImplementedError("Phase contrast not implemented on Cytation 1") - elif mode == ImagingMode.BRIGHTFIELD: - await self.send_command("Y", "P0c05") - await self.send_command("Y", "P0f02") - else: - await self.send_command("Y", f"P0c{filter_index:02}") - await self.send_command("Y", "P0f01") - else: - await self.send_command("Y", f"P0c{filter_index:02}") - - self._imaging_mode = mode - await self.led_on(intensity=led_intensity) - await asyncio.sleep(0.5) - - async def set_objective(self, objective: Objective) -> None: - """Rotate objective turret to the specified objective.""" - if objective == self._objective: - return - obj_code = self._objective_code(objective) - if self.version.startswith("1"): - await self.send_command("Y", f"P0d{obj_code:02}", timeout=60) - else: - await self.send_command("Y", f"P0e{obj_code:02}", timeout=60) - self._objective = objective - self._imaging_mode = None # force re-set after objective change - - async def set_focus(self, focal_position: FocalPosition) -> None: - """Move focus motor to the specified height (mm). - - Uses the same linear calibration as CytationBackend. - """ - if focal_position == "machine-auto": - raise ValueError( - "focal_position cannot be 'machine-auto'. " - "Use PLR's Microscopy auto-focus instead." - ) - - if focal_position == self._focal_height: - return - - slope, intercept = (10.637991436186072, 1.0243013203461762) - focus_integer = int( - float(focal_position) + intercept + slope * float(focal_position) * 1000 - ) - focus_str = str(focus_integer).zfill(5) - - if self._imaging_mode is None: - raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") - imaging_mode_code = self._imaging_mode_code(self._imaging_mode) - await self.send_command("i", f"F{imaging_mode_code}0{focus_str}") - - self._focal_height = focal_position - - async def led_on(self, intensity: int = 10) -> None: - """Turn on LED at specified intensity (1–10).""" - if not 1 <= intensity <= 10: - raise ValueError("intensity must be between 1 and 10") - if self._imaging_mode is None: - raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") - imaging_mode_code = self._imaging_mode_code(self._imaging_mode) - intensity_str = str(intensity).zfill(2) - await self.send_command("i", f"L0{imaging_mode_code}{intensity_str}") - - async def led_off(self) -> None: - """Turn off LED.""" - await self.send_command("i", "L0001") - - async def select(self, row: int, column: int) -> None: - """Move plate stage to a well position.""" - if row == self._row and column == self._column: - return - row_str = str(row).zfill(2) - col_str = str(column).zfill(2) - await self.send_command("Y", f"W6{row_str}{col_str}") - self._row, self._column = row, column - self._pos_x, self._pos_y = None, None - await self.set_position(0, 0) - - async def set_position(self, x: float, y: float) -> None: - """Fine-position the plate stage within a well (mm).""" - if self._imaging_mode is None: - raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") - - if x == self._pos_x and y == self._pos_y: - return - - x_str = str(round(x * 100 * 0.984)).zfill(6) - y_str = str(round(y * 100 * 0.984)).zfill(6) - - if self._row is None or self._column is None: - raise ValueError("Row and column not set. Call select() first.") - row_str = str(self._row).zfill(2) - column_str = str(self._column).zfill(2) - - if self._objective is None: - raise ValueError("Objective not set. Call set_objective() first.") - objective_code = self._objective_code(self._objective) - imaging_mode_code = self._imaging_mode_code(self._imaging_mode) - await self.send_command( - "Y", - f"Z{objective_code}{imaging_mode_code}6{row_str}{column_str}" - f"{y_str}{x_str}", - ) - - relative_x = x - (self._pos_x or 0) - relative_y = y - (self._pos_y or 0) - if relative_x != 0: - relative_x_str = str(round(relative_x * 100 * 0.984)).zfill(6) - await self.send_command("Y", f"O00{relative_x_str}") - if relative_y != 0: - relative_y_str = str(round(relative_y * 100 * 0.984)).zfill(6) - await self.send_command("Y", f"O01{relative_y_str}") - - self._pos_x, self._pos_y = x, y + def _imaging_mode_code(self, mode: ImagingMode) -> int: + """Get filter wheel position index for an imaging mode. + + Brightfield and phase contrast use position 5 (no filter cube). + """ + if mode == ImagingMode.BRIGHTFIELD or mode == ImagingMode.PHASE_CONTRAST: + return 5 + for i, f in enumerate(self.filters): + if f == mode: + return i + 1 + raise ValueError(f"Mode {mode} not found in filters: {self.filters}") + + def _objective_code(self, objective: Objective) -> int: + """Get turret position index for an objective.""" + for i, o in enumerate(self.objectives): + if o == objective: + return i + 1 + raise ValueError(f"Objective {objective} not found: {self.objectives}") + + async def set_imaging_mode(self, mode: ImagingMode, led_intensity: int = 10) -> None: + """Set filter wheel position and LED. + + Brightfield uses filter position 5 (empty slot) and light path + mode 02 (transmitted). Fluorescence modes use the filter cube + position and light path mode 01 (epifluorescence). + """ + if mode == self._imaging_mode: + await self.led_on(intensity=led_intensity) + return + + if mode == ImagingMode.COLOR_BRIGHTFIELD: + raise NotImplementedError("Color brightfield not implemented") + + await self.led_off() + filter_index = self._imaging_mode_code(mode) + + if self.version.startswith("1"): + if mode == ImagingMode.PHASE_CONTRAST: + raise NotImplementedError("Phase contrast not implemented on Cytation 1") + elif mode == ImagingMode.BRIGHTFIELD: + await self.send_command("Y", "P0c05") + await self.send_command("Y", "P0f02") + else: + await self.send_command("Y", f"P0c{filter_index:02}") + await self.send_command("Y", "P0f01") + else: + await self.send_command("Y", f"P0c{filter_index:02}") + + self._imaging_mode = mode + await self.led_on(intensity=led_intensity) + await asyncio.sleep(0.5) + + async def set_objective(self, objective: Objective) -> None: + """Rotate objective turret to the specified objective.""" + if objective == self._objective: + return + obj_code = self._objective_code(objective) + if self.version.startswith("1"): + await self.send_command("Y", f"P0d{obj_code:02}", timeout=60) + else: + await self.send_command("Y", f"P0e{obj_code:02}", timeout=60) + self._objective = objective + self._imaging_mode = None # force re-set after objective change + + async def set_focus(self, focal_position: FocalPosition) -> None: + """Move focus motor to the specified height (mm). + + Uses a linear calibration for focus motor positioning. + """ + if focal_position == "machine-auto": + raise ValueError( + "focal_position cannot be 'machine-auto'. Use PLR's Microscopy auto-focus instead." + ) + + if focal_position == self._focal_height: + return + + slope, intercept = (10.637991436186072, 1.0243013203461762) + focus_integer = int(float(focal_position) + intercept + slope * float(focal_position) * 1000) + focus_str = str(focus_integer).zfill(5) + + if self._imaging_mode is None: + raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") + imaging_mode_code = self._imaging_mode_code(self._imaging_mode) + await self.send_command("i", f"F{imaging_mode_code}0{focus_str}") + + self._focal_height = focal_position + + async def led_on(self, intensity: int = 10) -> None: + """Turn on LED at specified intensity (1–10).""" + if not 1 <= intensity <= 10: + raise ValueError("intensity must be between 1 and 10") + if self._imaging_mode is None: + raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") + imaging_mode_code = self._imaging_mode_code(self._imaging_mode) + intensity_str = str(intensity).zfill(2) + await self.send_command("i", f"L0{imaging_mode_code}{intensity_str}") + + async def led_off(self) -> None: + """Turn off LED.""" + await self.send_command("i", "L0001") + + async def select(self, row: int, column: int) -> None: + """Move plate stage to a well position.""" + if row == self._row and column == self._column: + return + row_str = str(row).zfill(2) + col_str = str(column).zfill(2) + await self.send_command("Y", f"W6{row_str}{col_str}") + self._row, self._column = row, column + self._pos_x, self._pos_y = None, None + await self.set_position(0, 0) + + async def set_position(self, x: float, y: float) -> None: + """Fine-position the plate stage within a well (mm).""" + if self._imaging_mode is None: + raise ValueError("Imaging mode not set. Call set_imaging_mode() first.") + + if x == self._pos_x and y == self._pos_y: + return + + x_str = str(round(x * 100 * 0.984)).zfill(6) + y_str = str(round(y * 100 * 0.984)).zfill(6) + + if self._row is None or self._column is None: + raise ValueError("Row and column not set. Call select() first.") + row_str = str(self._row).zfill(2) + column_str = str(self._column).zfill(2) + + if self._objective is None: + raise ValueError("Objective not set. Call set_objective() first.") + objective_code = self._objective_code(self._objective) + imaging_mode_code = self._imaging_mode_code(self._imaging_mode) + await self.send_command( + "Y", + f"Z{objective_code}{imaging_mode_code}6{row_str}{column_str}{y_str}{x_str}", + ) + + relative_x = x - (self._pos_x or 0) + relative_y = y - (self._pos_y or 0) + if relative_x != 0: + relative_x_str = str(round(relative_x * 100 * 0.984)).zfill(6) + await self.send_command("Y", f"O00{relative_x_str}") + if relative_y != 0: + relative_y_str = str(round(relative_y * 100 * 0.984)).zfill(6) + await self.send_command("Y", f"O01{relative_y_str}") + + self._pos_x, self._pos_y = x, y