diff --git a/_typos.toml b/_typos.toml index 9a066135d51..60c89b6908b 100644 --- a/_typos.toml +++ b/_typos.toml @@ -46,6 +46,12 @@ LOK = "LOK" ouput = "ouput" hegiht = "hegiht" +# Celigo vendor XML schema identifiers. These spellings appear in instrument +# configuration files and must remain exact for configuration loading. +Accleration = "Accleration" +Tranformation = "Tranformation" +tranformation = "tranformation" + [files] extend-exclude = [ "*.ipynb" diff --git a/docs/_static/devices.json b/docs/_static/devices.json index c6ec378ff72..efbfc049fc5 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -1180,6 +1180,22 @@ "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.qinstruments.com/automation/heatplate/" }, + { + "id": "revvity-celigo", + "vendor": "Revvity", + "name": "Celigo", + "kind": "microscope", + "capabilities": [ + "fluorescence", + "microscopy" + ], + "status": "basic", + "api": "pylabrobot.revvity.Celigo", + "api_version": "v1", + "code_slug": "revvity/celigo", + "doc_slug": "revvity/celigo/hello-world", + "manager": "https://discuss.pylabrobot.org/u/rickwierenga" + }, { "id": "sartorius-entris2", "vendor": "Sartorius", diff --git a/docs/api/pylabrobot.revvity.rst b/docs/api/pylabrobot.revvity.rst new file mode 100644 index 00000000000..32ea41bc4b4 --- /dev/null +++ b/docs/api/pylabrobot.revvity.rst @@ -0,0 +1,126 @@ +pylabrobot.revvity package +========================== + +Celigo +------ + +.. currentmodule:: pylabrobot.revvity.celigo + +Load the vendor configuration explicitly, construct the instrument, then assign the +PyLabRobot plate used for well navigation: + +.. code-block:: python + + from pylabrobot.revvity import Celigo, CeligoConfig + from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb + + config = CeligoConfig.from_install("/path/to/Celigo/ConfigFiles") + celigo = Celigo(config=config) + celigo.set_plate(Cor_96_wellplate_360ul_Fb(name="imaging_plate")) + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Celigo + AcquisitionResult + FocusResult + ControllerInfo + ControllerStatus + DetectedMotorAddress + SelfTestReport + +Camera +------ + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CeligoCamera + CameraFrame + CameraError + +Motion and optics +----------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Axis + LinearAxis + StepperMotor + MotorController + FilterWheel + MagnificationChanger + Galvo + GalvoControllerStatus + Laser + +Configuration +------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CeligoConfig + CeligoHardwareConfig + AxisConfig + LinearAxisConfig + FilterWheelConfig + FilterMapEntry + IOConfig + AnalogInputConfig + DigitalIOConfig + LightingIOConfig + HardwareDefaultConfig + NavigationConfig + CalibrationConfig + Calibrated2DPolynomialTransform + ChannelDescriptor + ExternalCameraControlConfig + GalvoAxisOpticalCalibration + GalvoConfig + GalvoMagnificationCalibration + GalvoOpticalCalibration + IlluminationChannelConfig + +Coordinates and navigation +-------------------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CoordinateSystems + well_to_stage_mm + +Configuration loaders +--------------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + load_channel_descriptors + load_galvo_calibrations + load_galvo_optical_calibration + load_illumination_channels + +Errors +------ + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CeligoError diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 67dd588713c..9a842454106 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -36,5 +36,6 @@ Manufacturers pylabrobot.micronic pylabrobot.molecular_devices pylabrobot.qinstruments + pylabrobot.revvity pylabrobot.sartorius pylabrobot.thermo_fisher diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 69f74875f52..4b3546dde2b 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -44,6 +44,7 @@ mettler_toledo/index micronic/index molecular_devices/index qinstruments/index +revvity/index sartorius/index thermo_fisher/index ufactory/index diff --git a/docs/user_guide/revvity/celigo/advanced-imaging.ipynb b/docs/user_guide/revvity/celigo/advanced-imaging.ipynb new file mode 100644 index 00000000000..06cb0e2a016 --- /dev/null +++ b/docs/user_guide/revvity/celigo/advanced-imaging.ipynb @@ -0,0 +1,437 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title", + "metadata": {}, + "source": [ + "# Advanced imaging and coordinates\n", + "\n", + "This notebook goes beyond the [Celigo hello world](hello-world.ipynb): it inspects the installed imaging configuration, plans calibrated coordinates and galvo fields of view, works directly with `CameraFrame`, tunes exposure and autofocus, and inspects structured acquisition results.\n", + "\n", + "The executable cells move the stage, Z axis, filter wheel, and galvos and switch illumination. Clear the motion envelope, seat the plate correctly, and keep the final cleanup cell available before continuing." + ] + }, + { + "cell_type": "markdown", + "id": "imports-note", + "metadata": {}, + "source": [ + "## Configure the instrument\n", + "\n", + "`CeligoConfig.from_install(install_dir)` loads the complete, per-instrument configuration from an explicit installation root, `ConfigFiles` directory, or hardware-config path. Assign the plate after constructing `Celigo`; the plate is not a constructor setting." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "imports", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "from pylabrobot.revvity import Capture, Celigo, CeligoConfig, ScanSpec\n", + "from pylabrobot.revvity.celigo import CoordinateSystems\n", + "from pylabrobot.revvity.celigo.navigation import galvo_field_of_view_offsets_mm\n", + "from pylabrobot.resources.corning.plates import cor_96_wellplate_360uL_Fb" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "configuration", + "metadata": {}, + "outputs": [], + "source": [ + "config_root = Path(\"/path/to/Celigo/ConfigFiles\")\n", + "lucam_sdk = Path(\"/path/to/liblucamapi.so\")\n", + "usb_address = \"3-2\"\n", + "\n", + "config = CeligoConfig.from_install(str(config_root))\n", + "plate = cor_96_wellplate_360uL_Fb(name=\"imaging_plate\")\n", + "celigo = Celigo(\n", + " config=config,\n", + " usb_address=usb_address,\n", + " lucam_sdk=str(lucam_sdk),\n", + ")\n", + "celigo.set_plate(plate)" + ] + }, + { + "cell_type": "markdown", + "id": "connect-note", + "metadata": {}, + "source": [ + "## Connect and establish position references\n", + "\n", + "`setup()` initializes the controller and camera, configures the motors and galvos, then homes Z, X, Y, and the dichroic filter in that clearance-safe order." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "connect", + "metadata": {}, + "outputs": [], + "source": [ + "await celigo.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "channels-note", + "metadata": {}, + "source": [ + "## Inspect the installed channel recipes\n", + "\n", + "Channel configuration is magnification-specific. Each recipe supplies the logical filter, lighting output, default intensity, Z correction, and pixel-scale correction used by `acquire()` and scan planning." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "channels", + "metadata": {}, + "outputs": [], + "source": [ + "channel_summary = {\n", + " name: {\n", + " \"logical_filter\": channel.logical_filter,\n", + " \"lighting_output\": channel.lighting_io_name,\n", + " \"intensity_percent\": channel.intensity_percent,\n", + " \"z_offset_mm\": channel.z_offset_to_brightfield_mm,\n", + " \"pixel_scale\": (\n", + " channel.mm_per_pixel_x_correction_to_brightfield,\n", + " channel.mm_per_pixel_y_correction_to_brightfield,\n", + " ),\n", + " }\n", + " for name, channel in celigo.config.channels.items()\n", + "}\n", + "celigo.config.magnification, channel_summary" + ] + }, + { + "cell_type": "markdown", + "id": "coordinates-note", + "metadata": {}, + "source": [ + "## Plan calibrated coordinates without moving\n", + "\n", + "`well_position_mm()` converts a standard PyLabRobot well into calibrated stage millimeters. `CoordinateSystems` also converts between plate-relative sample millimeters, stage millimeters, and pixels. For image conversions, `reference_point_mm` is the field center in sample coordinates; the calibrated center pixel maps to that sample point and its corresponding stage position." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "coordinates", + "metadata": {}, + "outputs": [], + "source": [ + "plate_coordinates = CoordinateSystems.from_config(\n", + " celigo.config.calibration,\n", + " celigo.config.hardware_defaults,\n", + ")\n", + "a1_stage_mm = celigo.well_position_mm(\"A1\")\n", + "a1_sample_mm = plate_coordinates.stage_mm_to_sample_mm(*a1_stage_mm)\n", + "field_coordinates = CoordinateSystems.from_config(\n", + " celigo.config.calibration,\n", + " celigo.config.hardware_defaults,\n", + " reference_point_mm=a1_sample_mm,\n", + ")\n", + "center_pixel = (\n", + " celigo.config.calibration.image_width_pixels / 2,\n", + " celigo.config.calibration.image_height_pixels / 2,\n", + ")\n", + "center_sample_mm = field_coordinates.image_pixel_to_sample_mm(*center_pixel)\n", + "center_stage_mm = field_coordinates.image_pixel_to_stage_mm(*center_pixel)\n", + "{\n", + " \"A1 stage mm\": a1_stage_mm,\n", + " \"A1 sample mm\": a1_sample_mm,\n", + " \"center pixel\": center_pixel,\n", + " \"center pixel sample mm\": center_sample_mm,\n", + " \"center pixel stage mm\": center_stage_mm,\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "fov-plan-note", + "metadata": {}, + "source": [ + "## Preview the galvo FOV plan\n", + "\n", + "The navigation calibration defines a centered serpentine grid of sample-space offsets. `Galvo.voltages_for_offset()` combines one offset with the active magnification center, the logical-filter correction, and the calibrated inverse polynomial. These calculations do not move hardware." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fov-plan", + "metadata": {}, + "outputs": [], + "source": [ + "brightfield_filter = celigo.config.channels[\"brightfield\"].logical_filter\n", + "fov_offsets_mm = galvo_field_of_view_offsets_mm(\n", + " celigo.config.calibration,\n", + " celigo.config.navigation,\n", + ")\n", + "fov_plan = [\n", + " {\n", + " \"offset_mm\": offset_mm,\n", + " \"logical_voltages\": celigo.galvo.voltages_for_offset(\n", + " brightfield_filter,\n", + " offset_mm,\n", + " ),\n", + " }\n", + " for offset_mm in fov_offsets_mm\n", + "]\n", + "fov_plan" + ] + }, + { + "cell_type": "markdown", + "id": "frame-note", + "metadata": {}, + "source": [ + "## Capture and analyze a `CameraFrame`\n", + "\n", + "`capture_frame()` captures at the current stage, Z, filter, galvo, and illumination state. The following cells establish that state explicitly. `CameraFrame` stores dependency-free monochrome bytes and exposes statistics, sharpness, PGM export, and optional NumPy conversion." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "establish-field", + "metadata": {}, + "outputs": [], + "source": [ + "await celigo.move_to_well(\"A1\", retract_z=True)\n", + "await celigo.select_channel(\"brightfield\")\n", + "await celigo.z_axis.move_to(celigo.config.calibration.calibrated_z_position)\n", + "await celigo.galvo.home(logical_filter=brightfield_filter)\n", + "await celigo.set_camera_exposure_and_gain(\n", + " exposure_ms=1.0,\n", + " gain=1.0,\n", + " restart_camera_stream=True,\n", + ")\n", + "await celigo.set_illumination_enabled(True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "frame-analysis", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " frame = await celigo.capture_frame(flush_frames=2)\n", + "finally:\n", + " await celigo.turn_off_illumination()\n", + "frame.save_pgm(\"A1-brightfield-direct.pgm\")\n", + "{\n", + " \"shape\": (frame.height, frame.width),\n", + " \"bit_depth\": frame.bit_depth,\n", + " \"exposure_ms\": frame.exposure_ms,\n", + " \"gain\": frame.gain,\n", + " \"statistics\": frame.statistics(),\n", + " \"sharpness\": frame.sharpness(sample_step=8),\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "numpy-note", + "metadata": {}, + "source": [ + "NumPy is optional. When installed, `to_numpy()` returns a two-dimensional `uint8` or `uint16` view suitable for scientific image tooling." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "numpy", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " image = frame.to_numpy()\n", + "except ImportError as error:\n", + " print(error)\n", + "else:\n", + " print(image.shape, image.dtype)" + ] + }, + { + "cell_type": "markdown", + "id": "exposure-note", + "metadata": {}, + "source": [ + "## Tune exposure directly\n", + "\n", + "`auto_exposure()` tests only the supplied positive candidates, from left to right. It chooses the first frame that is bright enough while keeping the saturated-pixel fraction below the requested limit. It does not move or select a channel, so establish the field first as above." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "exposure", + "metadata": {}, + "outputs": [], + "source": [ + "selected_exposure_ms, exposure_frame = await celigo.auto_exposure(\n", + " candidates_ms=(10.0, 5.0, 2.0, 1.0, 0.5),\n", + " saturation_fraction=0.01,\n", + " minimum_mean_fraction=0.03,\n", + ")\n", + "selected_exposure_ms, exposure_frame.statistics()" + ] + }, + { + "cell_type": "markdown", + "id": "autofocus-note", + "metadata": {}, + "source": [ + "## Inspect a direct autofocus result\n", + "\n", + "The high-level acquisition API accepts `autofocus=\"image\"`. Calling `autofocus()` directly additionally exposes the sampled Z ticks and scores. Its span and step arguments are controller-native encoder ticks; use the Z-axis conversion helpers when starting from millimeters. The scan restores the initial Z position on failure and rejects flat focus curves and boundary optima." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "autofocus", + "metadata": {}, + "outputs": [], + "source": [ + "center_z_ticks = await celigo.z_axis.request_encoder_ticks()\n", + "focus = await celigo.autofocus(\n", + " center_z_ticks=center_z_ticks,\n", + " span_ticks=1500,\n", + " coarse_step_ticks=250,\n", + " fine_step_ticks=75,\n", + ")\n", + "focus.frame.save_pgm(\"A1-brightfield-focused-direct.pgm\")\n", + "{\n", + " \"z_ticks\": focus.z_ticks,\n", + " \"z_mm\": focus.z_mm,\n", + " \"verified_score\": focus.score,\n", + " \"samples\": focus.scored_z_samples,\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "acquisition-result-note", + "metadata": {}, + "source": [ + "## Inspect structured acquisition metadata\n", + "\n", + "`AcquisitionResult` records the requested well and channel, settled X/Y/Z millimeters, the final frame, optional `FocusResult`, and the hardware galvo voltages. Acquisition extinguishes illumination if any step fails or is cancelled." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "acquisition-result", + "metadata": {}, + "outputs": [], + "source": [ + "result = await celigo.acquire(\n", + " \"A1\",\n", + " \"brightfield\",\n", + " exposure_ms=selected_exposure_ms,\n", + " gain=1.0,\n", + " autofocus=\"image\",\n", + " galvo_offset_mm=fov_offsets_mm[0],\n", + ")\n", + "{\n", + " \"label\": result.label,\n", + " \"channel\": result.channel,\n", + " \"stage_mm\": (result.x_mm, result.y_mm),\n", + " \"z_mm\": result.z_mm,\n", + " \"galvo_hardware_voltages\": result.galvo_hardware_voltages,\n", + " \"focus_score\": None if result.focus is None else result.focus.score,\n", + " \"frame_statistics\": result.frame.statistics(),\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "scan-note", + "metadata": {}, + "source": [ + "## Build and execute a multichannel scan\n", + "\n", + "`ScanSpec.wells()` converts well names to physical centers and stores every capture setting. `plan()` is offline; `execute()` accepts no scientific overrides and runs the inspected operations exactly. The coarse stage moves once per block." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "scan", + "metadata": {}, + "outputs": [], + "source": [ + "scan_spec = ScanSpec.wells(\n", + " plate,\n", + " [\"A1\", \"A2\"],\n", + " block_shape=(2, 3),\n", + " captures=[\n", + " Capture(channel=\"brightfield\", exposure_ms=selected_exposure_ms, gain=1.0),\n", + " Capture(channel=\"green\", exposure_ms=10.0, gain=1.0),\n", + " ],\n", + " autofocus=\"image\",\n", + ")\n", + "scan_plan = celigo.plan(scan_spec)\n", + "print(scan_plan)\n", + "\n", + "scan_result = await celigo.execute(scan_plan)\n", + "[\n", + " (\n", + " item.planned.block.label,\n", + " item.planned.capture.channel,\n", + " item.actual_stage_mm,\n", + " item.actual_z_mm,\n", + " )\n", + " for item in scan_result.frames\n", + "]" + ] + }, + { + "cell_type": "markdown", + "id": "cleanup-note", + "metadata": {}, + "source": [ + "## Stop safely\n", + "\n", + "Run cleanup even after an exception. `turn_off_illumination()` attempts every configured lighting output even if one output fails; `stop()` also aborts controller work, clears safe outputs, closes the camera, and releases FTDI." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cleanup", + "metadata": {}, + "outputs": [], + "source": [ + "await celigo.turn_off_illumination()\n", + "await celigo.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/revvity/celigo/components-and-diagnostics.ipynb b/docs/user_guide/revvity/celigo/components-and-diagnostics.ipynb new file mode 100644 index 00000000000..685ce0bb5e8 --- /dev/null +++ b/docs/user_guide/revvity/celigo/components-and-diagnostics.ipynb @@ -0,0 +1,568 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title", + "metadata": {}, + "source": [ + "# Hardware components and diagnostics\n", + "\n", + "This notebook covers the Celigo APIs below high-level acquisition: controller status, motor and axis objects, filter wheels, galvo diagnostics, board I/O, barcode and camera-trigger interfaces, active self-tests, and the guarded laser component.\n", + "\n", + "Some cells move mechanisms or change illumination. Clear the stage, Z, drawer, filter, and objective paths before running them. Every executable laser cell is read-only; this notebook cannot fire a laser when run from top to bottom." + ] + }, + { + "cell_type": "markdown", + "id": "configure-note", + "metadata": {}, + "source": [ + "## Load configuration and inspect this build\n", + "\n", + "Celigo mechanisms are optional because instrument builds differ. The aggregate configuration is the source of truth for which axes, wheels, galvos, I/O lines, and camera signals exist." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "imports", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "from pylabrobot.revvity import Celigo, CeligoConfig\n", + "from pylabrobot.revvity.celigo import CeligoError" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "configuration", + "metadata": {}, + "outputs": [], + "source": [ + "config_root = Path(\"/path/to/Celigo/ConfigFiles\")\n", + "lucam_sdk = Path(\"/path/to/liblucamapi.so\")\n", + "usb_address = \"3-2\"\n", + "\n", + "config = CeligoConfig.from_install(str(config_root))\n", + "celigo = Celigo(\n", + " config=config,\n", + " usb_address=usb_address,\n", + " lucam_sdk=str(lucam_sdk),\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "inventory", + "metadata": {}, + "outputs": [], + "source": [ + "mechanism_configs = {\n", + " \"x_axis\": config.hardware.x_axis,\n", + " \"y_axis\": config.hardware.y_axis,\n", + " \"z_axis\": config.hardware.z_axis,\n", + " \"beam_expander\": config.hardware.beam_expander,\n", + " \"camera_filter\": config.hardware.camera_filter_wheel,\n", + " \"dichroic_filter\": config.hardware.dichroic_filter_wheel,\n", + " \"excitation_filter\": config.hardware.excitation_filter_wheel,\n", + " \"excitation_nd_filter\": config.hardware.excitation_nd_filter_wheel,\n", + " \"laser_attenuator\": config.hardware.laser_attenuator,\n", + " \"laser_nd_filter\": config.hardware.laser_nd_filter_wheel,\n", + " \"magnification_changer\": config.hardware.magnification_changer,\n", + "}\n", + "{\n", + " name: {\n", + " \"motion_name\": mechanism.motion_name,\n", + " \"axis_index\": mechanism.axis_index,\n", + " \"enabled\": mechanism.enabled,\n", + " }\n", + " for name, mechanism in mechanism_configs.items()\n", + " if mechanism is not None\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "connect-note", + "metadata": {}, + "source": [ + "## Connect and read controller identity\n", + "\n", + "`ControllerStatus` retains the raw controller flags and exposes named properties. A generic interlock flag is a laser-safety fault, but it is not by itself a general controller failure. `DetectedMotorAddress` is a discovery record containing the controller UART index and EZStepper motor index; it is not a motor object." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "connect", + "metadata": {}, + "outputs": [], + "source": [ + "await celigo.setup()\n", + "controller_status = await celigo.request_controller_status()\n", + "motor_addresses = await celigo.request_detected_motor_addresses()\n", + "{\n", + " \"controller_info\": celigo.controller_info,\n", + " \"firmware_version\": celigo.controller_firmware_version,\n", + " \"status\": controller_status,\n", + " \"busy\": controller_status.busy,\n", + " \"controller_fault\": controller_status.has_controller_fault,\n", + " \"laser_safety_fault\": controller_status.has_laser_safety_fault,\n", + " \"motor_addresses\": motor_addresses,\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "self-test-note", + "metadata": {}, + "source": [ + "## Run the read-only self-test\n", + "\n", + "The default self-test reads status, identity, motor mapping, encoders, encoder ratios, digital inputs, configured galvo-calibration success, and camera diagnostic inputs. It does not move the stage or capture a frame." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "self-test", + "metadata": {}, + "outputs": [], + "source": [ + "report = await celigo.run_self_test()\n", + "report.passed, report.failures, report.checks" + ] + }, + { + "cell_type": "markdown", + "id": "axes-note", + "metadata": {}, + "source": [ + "## Inspect axis objects and units\n", + "\n", + "`LinearAxis` owns conversion between millimeters and encoder ticks. Its normal `move_to()` interface accepts millimeters. `move_to_ticks()` is available for controller-native diagnostics and requires an established position reference. Limit methods return polarity-corrected logical states." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "axis-snapshot", + "metadata": {}, + "outputs": [], + "source": [ + "axis_snapshot = {}\n", + "for axis in (celigo.x_axis, celigo.y_axis, celigo.z_axis):\n", + " encoder_ticks = await axis.request_encoder_ticks()\n", + " axis_snapshot[axis.name] = {\n", + " \"axis_index\": axis.axis_index,\n", + " \"initialized\": axis.is_initialized,\n", + " \"position_reference\": axis.has_position_reference,\n", + " \"encoder_ticks\": encoder_ticks,\n", + " \"encoder_ratio\": await axis.request_encoder_ratio(),\n", + " \"negative_limit\": await axis.request_is_negative_limit_active(),\n", + " \"positive_limit\": await axis.request_is_positive_limit_active(),\n", + " }\n", + "axis_snapshot" + ] + }, + { + "cell_type": "markdown", + "id": "motion-note", + "metadata": {}, + "source": [ + "## Home and make bounded moves\n", + "\n", + "`setup()` has already homed the imaging axes. Rehoming here demonstrates the diagnostic explicitly: Z first provides vertical clearance, and each linear home proves encoder response, reaches and releases the configured negative limit, establishes the encoder datum, restores the configured controller mode, and verifies the final position. The example then performs a one-millimeter X move within the configured bounds." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "motion", + "metadata": {}, + "outputs": [], + "source": [ + "await celigo.home_imaging_axes()\n", + "x_start_ticks = await celigo.x_axis.request_encoder_ticks()\n", + "x_start_mm = celigo.x_axis.encoder_ticks_to_mm(x_start_ticks)\n", + "x_target_mm = min(x_start_mm + 1.0, celigo.x_axis.config.max_position)\n", + "x_settled_mm = await celigo.x_axis.move_to(x_target_mm)\n", + "await celigo.x_axis.move_to(x_start_mm)\n", + "x_start_mm, x_settled_mm" + ] + }, + { + "cell_type": "markdown", + "id": "filters-note", + "metadata": {}, + "source": [ + "## Work with filter-wheel components\n", + "\n", + "A `FilterWheel` learns physical position one during `home()`. `move_to()` accepts a configured logical position and chooses the shortest encoder-equivalent target. Channel selection normally moves the dichroic wheel for you; direct wheel control is useful for diagnostics." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "filters", + "metadata": {}, + "outputs": [], + "source": [ + "logical_filter_map = {\n", + " entry.logical_number: entry.physical_number for entry in celigo.dichroic_filter.config.filter_map\n", + "}\n", + "brightfield_position = celigo.config.channels[\"brightfield\"].logical_filter\n", + "settled_filter_ticks = await celigo.dichroic_filter.move_to(brightfield_position)\n", + "logical_filter_map, settled_filter_ticks" + ] + }, + { + "cell_type": "markdown", + "id": "magnification-note", + "metadata": {}, + "source": [ + "If a magnification changer is configured, its logical positions are `3`, `5`, `10`, and `20`. Moving it also changes `config.magnification`, so subsequent channel, pixel-scale, and galvo calibration lookups use the selected objective. The mechanism is optional, so check the hardware configuration before accessing `celigo.magnification_changer`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "magnification", + "metadata": {}, + "outputs": [], + "source": [ + "magnification_config = config.hardware.magnification_changer\n", + "if (\n", + " magnification_config is not None\n", + " and magnification_config.enabled\n", + " and magnification_config.axis_index > 0\n", + "):\n", + " await celigo.magnification_changer.home()\n", + " await celigo.magnification_changer.move_to(3)\n", + "celigo.config.magnification" + ] + }, + { + "cell_type": "markdown", + "id": "galvo-note", + "metadata": {}, + "source": [ + "## Read galvo diagnostics\n", + "\n", + "`GalvoControllerStatus` contains per-axis busy states and hardware voltages together with the shared targeting-controller state. `request_calibration_errors()` returns controller-native error-count pairs. `request_position_trace_dac_counts()` returns raw DAC-count trace pairs because the firmware trace is not a calibrated physical-position API." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "galvo-status", + "metadata": {}, + "outputs": [], + "source": [ + "galvo_status = await celigo.galvo.request_controller_status()\n", + "x_calibration_errors = await celigo.galvo.request_calibration_errors(\"x\")\n", + "y_calibration_errors = await celigo.galvo.request_calibration_errors(\"y\")\n", + "{\n", + " \"status\": galvo_status,\n", + " \"x_calibration_errors\": x_calibration_errors,\n", + " \"y_calibration_errors\": y_calibration_errors,\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "galvo-motion-note", + "metadata": {}, + "source": [ + "Galvo motion uses logical volts and seconds. `home()` moves both axes to the calibrated center for the active magnification and optional logical-filter offset. `move_single()` and `move_both()` validate configured voltage bounds and return the actual hardware voltages after inversion." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "galvo-motion", + "metadata": {}, + "outputs": [], + "source": [ + "brightfield_filter = celigo.config.channels[\"brightfield\"].logical_filter\n", + "center_hardware_voltages = await celigo.galvo.home(\n", + " logical_filter=brightfield_filter,\n", + ")\n", + "center_hardware_voltages" + ] + }, + { + "cell_type": "markdown", + "id": "io-note", + "metadata": {}, + "source": [ + "## Inspect controller I/O\n", + "\n", + "Digital ports are 12-bit bitmasks. Analog outputs and inputs expose raw 12-bit counts; voltage helpers require the applicable per-channel minimum and maximum. Prefer `select_channel()`, `set_brightfield_enabled()`, and `turn_off_illumination()` over raw output writes for normal imaging." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "io-inventory", + "metadata": {}, + "outputs": [], + "source": [ + "io_config = celigo.config.hardware.io\n", + "if io_config is None:\n", + " raise RuntimeError(\"This instrument has no configured controller I/O\")\n", + "\n", + "io_inventory = {\n", + " \"digital\": [\n", + " (line.io_name, line.io_type, line.bit_index, line.invert) for line in io_config.digital_ios\n", + " ],\n", + " \"lighting\": [\n", + " (output.io_name, output.channel, output.min_voltage, output.max_voltage)\n", + " for output in io_config.lighting_ios\n", + " ],\n", + " \"analog_inputs\": [\n", + " (input_config.io_name, input_config.channel) for input_config in io_config.analog_ins\n", + " ],\n", + "}\n", + "io_inventory" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "io-readback", + "metadata": {}, + "outputs": [], + "source": [ + "lighting_readback = {}\n", + "for output in io_config.lighting_ios:\n", + " dac_count = await celigo.request_analog_output_count(output.channel)\n", + " voltage = await celigo.request_analog_output_voltage(\n", + " output.channel,\n", + " output.min_voltage,\n", + " output.max_voltage,\n", + " )\n", + " lighting_readback[output.io_name] = {\n", + " \"dac_count\": dac_count,\n", + " \"voltage\": voltage,\n", + " }\n", + "{\n", + " \"digital_inputs\": await celigo.request_digital_input_bitmask(),\n", + " \"digital_outputs\": await celigo.request_digital_output_bitmask(),\n", + " \"lighting\": lighting_readback,\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "illumination-note", + "metadata": {}, + "source": [ + "## Control illumination through named APIs\n", + "\n", + "`select_channel()` applies the configured filter, lamp-select bits, and galvo center while leaving illumination off. `set_illumination_enabled()` then uses the channel's configured intensity or an optional percentage override. Fluorescence lamp power enforces the configured minimum toggle interval and can optionally enforce warm-up before acquisition." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "illumination", + "metadata": {}, + "outputs": [], + "source": [ + "await celigo.select_channel(\"brightfield\")\n", + "await celigo.set_illumination_enabled(True, intensity_percent=50)\n", + "await celigo.turn_off_illumination()\n", + "{\n", + " \"fluorescence_lamp_ready\": celigo.fluorescence_lamp_ready,\n", + " \"fluorescence_warmup_remaining_s\": celigo.fluorescence_warmup_remaining,\n", + " \"can_change_fluorescence_power\": celigo.can_change_fluorescence_power,\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "camera-signals-note", + "metadata": {}, + "source": [ + "## Read camera synchronization signals\n", + "\n", + "The camera diagnostics are controller-board signal reads, separate from the Lumenera SDK. `None` means the installed firmware does not expose that input. `pulse_camera_trigger()` and `set_camera_trigger_line()` actively change the trigger output and should be used only with a configured external-trigger workflow." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "camera-signals", + "metadata": {}, + "outputs": [], + "source": [ + "{\n", + " \"busy\": await celigo.request_is_camera_busy(),\n", + " \"integrating\": await celigo.request_is_camera_integrating(),\n", + " \"trigger_encoder_ticks\": await celigo.request_camera_trigger_encoder_ticks(),\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "barcode-note", + "metadata": {}, + "source": [ + "## Barcode UART\n", + "\n", + "`request_barcode()` reads the ASCII response buffered by the controller. `send_barcode_command()` sends an ASCII command, but on some builds this UART is shared with the front-panel status display; consult the instrument-specific command set before writing to it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "barcode", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " barcode_response = await celigo.request_barcode()\n", + "except CeligoError as error:\n", + " barcode_response = f\"Barcode interface unavailable: {error}\"\n", + "barcode_response" + ] + }, + { + "cell_type": "markdown", + "id": "active-test-note", + "metadata": {}, + "source": [ + "## Opt into active diagnostics\n", + "\n", + "`run_active_checks=True` centers the galvos and captures a calibrated frame. `run_motion_checks=True` additionally performs a five-encoder-tick round trip on X, Y, and Z and is rejected unless active checks are also enabled. Use motion checks only after homing and clearing the full motion envelope." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "active-test", + "metadata": {}, + "outputs": [], + "source": [ + "active_report = await celigo.run_self_test(\n", + " run_active_checks=True,\n", + " run_motion_checks=False,\n", + ")\n", + "active_report.passed, active_report.failures, active_report.checks" + ] + }, + { + "cell_type": "markdown", + "id": "laser-status-note", + "metadata": {}, + "source": [ + "## Read the laser safety state\n", + "\n", + "`celigo.laser` always exists, but firing and laser UART commands are disabled unless the constructor receives `allow_laser=True`. The read-only board status remains available without that opt-in. Shared galvo and targeting-engine state is reported by `celigo.galvo.request_controller_status()`, demonstrated above; it is not the status of either individual laser." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "laser-status", + "metadata": {}, + "outputs": [], + "source": [ + "{\n", + " \"laser_commands_enabled\": celigo.laser.enabled,\n", + " \"controller_laser_safety_fault\": (\n", + " await celigo.request_controller_status()\n", + " ).has_laser_safety_fault,\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "laser-safety-boundary", + "metadata": {}, + "source": [ + "## Laser safety boundary\n", + "\n", + "**Do not convert the following examples into executable cells until the instrument's laser-safety procedure, enclosure state, sample target, optical path, and exposure controls have been independently verified.** Laser index `0` is `LASER_1`; index `1` is `LASER_2`. Delays are expressed in seconds. Target and grid coordinates are logical galvo-voltage offsets or sizes, not millimeters.\n", + "\n", + "Laser commands require a separately constructed instrument with explicit opt-in:\n", + "\n", + "```python\n", + "laser_celigo = Celigo(\n", + " config=config,\n", + " usb_address=usb_address,\n", + " lucam_sdk=str(lucam_sdk),\n", + " allow_laser=True,\n", + ")\n", + "await laser_celigo.setup()\n", + "```\n", + "\n", + "The guarded operations are:\n", + "\n", + "```python\n", + "await laser_celigo.laser.send_command(\"\")\n", + "reply = await laser_celigo.laser.request_uart_response()\n", + "await laser_celigo.laser.fire(laser_index=0, shots=1, delay=0.0)\n", + "await laser_celigo.laser.fire_targets(\n", + " voltage_offsets=[(0.0, 0.0)],\n", + " laser_index=0,\n", + " pulses=1,\n", + " delay_between_pulses=0.0,\n", + ")\n", + "laser_center_voltages = (\n", + " config.galvo_optical_calibration.x.laser_center_voltage,\n", + " config.galvo_optical_calibration.y.laser_center_voltage,\n", + ")\n", + "await laser_celigo.laser.fire_grid(\n", + " laser_index=0,\n", + " spacing_voltages=(0.05, 0.05),\n", + " size_voltages=(0.1, 0.1),\n", + " center_voltages=laser_center_voltages,\n", + " pulses=1,\n", + " repeats=1,\n", + ")\n", + "```\n", + "\n", + "Every guarded command rechecks controller safety immediately before acting. `laser.nd_filter` and `laser.attenuator` expose their configured motor components when present." + ] + }, + { + "cell_type": "markdown", + "id": "cleanup-note", + "metadata": {}, + "source": [ + "## Stop safely\n", + "\n", + "Always stop the instrument after diagnostics. This aborts controller work, clears safe outputs, closes the camera, and releases FTDI." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cleanup", + "metadata": {}, + "outputs": [], + "source": [ + "await celigo.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/revvity/celigo/hello-world.ipynb b/docs/user_guide/revvity/celigo/hello-world.ipynb new file mode 100644 index 00000000000..3dc71f41f13 --- /dev/null +++ b/docs/user_guide/revvity/celigo/hello-world.ipynb @@ -0,0 +1,501 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title-status", + "metadata": {}, + "source": [ + "# Celigo hello world\n", + "\n", + "This walkthrough uses the PyLabRobot driver directly; no Celigo vendor application is required. Keep the stage and drawer paths clear whenever motion is enabled." + ] + }, + { + "cell_type": "markdown", + "id": "device-card", + "metadata": {}, + "source": [ + "```{device-card} revvity-celigo\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "gaps", + "metadata": {}, + "source": [ + "## Prepare\n", + "\n", + "### Current driver scope\n", + "\n", + "The Lumenera opens at `2464x2056`; `Celigo.setup()` applies and reads back the centered `2048x2048` ROI required by `CalibrationConfig.xml` (offset `208, 4`), then homes Z, X, Y, and the dichroic filter. `Celigo.acquire()` rejects any later geometry mismatch rather than silently producing uncalibrated data.\n", + "\n", + "Image autofocus has been exercised on an A1 cell sample; hardware displacement-sensor autofocus is not implemented. Well navigation uses the geometry of the assigned PyLabRobot plate resource. All five configured illumination channels have completed acquisition. Camera-trigger diagnostics are verified, while externally triggered frame acquisition is not. Laser support is disabled by default, and firing has not been exercised." + ] + }, + { + "cell_type": "markdown", + "id": "communication", + "metadata": {}, + "source": [ + "### Connections and configuration\n", + "\n", + "One plain `Celigo` object owns both connections. It talks to the USB-I/O controller through PyLabRobot's FTDI transport, manages the `LumeneraCamera` lifecycle through `liblucamapi`, and loads motor limits, homing profiles, illumination channels, filter mappings, galvo centers, and coordinate transforms from the copied instrument configuration." + ] + }, + { + "cell_type": "markdown", + "id": "physical-setup", + "metadata": {}, + "source": [ + "### Physical setup\n", + "\n", + "1. Power on the Celigo and clear the stage, objective, and drawer paths.\n", + "2. Connect both USB devices: FTDI `0403:6001` for the controller and Lumenera `1724:0645` for the camera.\n", + "3. If more than one matching FTDI is attached, find the controller's local topology with `lsusb -t` and set `usb_address` below.\n", + "4. Copy the instrument's `ConfigFiles` directory and identify the matching plate model in `pylabrobot.resources`." + ] + }, + { + "cell_type": "markdown", + "id": "imports-note", + "metadata": {}, + "source": [ + "## Configure the driver\n", + "\n", + "### Imports\n", + "\n", + "The public device API is `Celigo`. It owns the camera internally, and a normal PyLabRobot `Plate` is assigned after construction." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "imports", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "from pylabrobot.revvity import Celigo, CeligoConfig\n", + "from pylabrobot.resources.corning.plates import cor_96_wellplate_360uL_Fb" + ] + }, + { + "cell_type": "markdown", + "id": "configuration-note", + "metadata": {}, + "source": [ + "### Load this instrument's configuration\n", + "\n", + "Load the complete instrument configuration with `CeligoConfig.from_install(config_root)` and pass it to `Celigo`. `config_root` may be the Celigo installation root, its `ConfigFiles` directory, or `USBIOHardwareConfig.config` itself. Use the PyLabRobot model matching the physical plate. `Celigo.setup()` initializes the controller and camera, applies the calibrated camera ROI, configures the motors and galvos, and homes Z, X, Y, and the dichroic filter." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "configuration", + "metadata": {}, + "outputs": [], + "source": [ + "config_root = Path(\"/path/to/Celigo/ConfigFiles\")\n", + "lucam_sdk = Path(\"/path/to/liblucamapi.so\")\n", + "usb_address = \"3-2\" # local -[....] from lsusb/pyusb\n", + "\n", + "config = CeligoConfig.from_install(str(config_root))\n", + "plate = cor_96_wellplate_360uL_Fb(name=\"imaging_plate\")\n", + "celigo = Celigo(\n", + " config=config,\n", + " usb_address=usb_address,\n", + " lucam_sdk=str(lucam_sdk),\n", + ")\n", + "celigo.set_plate(plate)" + ] + }, + { + "cell_type": "markdown", + "id": "setup-note", + "metadata": {}, + "source": [ + "## Initialize and verify\n", + "\n", + "### Connect and initialize\n", + "\n", + "The single setup call opens the controller and camera, performs the binary handshake, reads identity, discovers motors, applies safe outputs, initializes configured motor parameters, calibrates the galvos, applies the calibrated camera ROI, and homes Z, X, Y, and the dichroic filter." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "setup", + "metadata": {}, + "outputs": [], + "source": [ + "await celigo.setup()\n", + "celigo.controller_info, await celigo.request_detected_motor_addresses()" + ] + }, + { + "cell_type": "markdown", + "id": "self-test-note", + "metadata": {}, + "source": [ + "### Run the read-only self-test\n", + "\n", + "The default self-test reads controller status, identity, motor mapping and encoder ratios, digital inputs, and galvo calibration metadata without moving the stage. The tested instrument reports generic interlock flag `4`; that flag remains a laser-safety condition but does not by itself indicate a general controller failure." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "self-test", + "metadata": {}, + "outputs": [], + "source": [ + "self_test_report = await celigo.run_self_test()\n", + "self_test_report.passed, self_test_report.failures, self_test_report.checks" + ] + }, + { + "cell_type": "markdown", + "id": "geometry-note", + "metadata": {}, + "source": [ + "### Check camera geometry\n", + "\n", + "Setup should make these equal by applying and verifying the centered native ROI. Calibrated acquisition also checks every returned frame." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "geometry", + "metadata": {}, + "outputs": [], + "source": [ + "actual_format = (celigo.camera.width, celigo.camera.height)\n", + "expected_format = (\n", + " celigo.config.calibration.image_width_pixels,\n", + " celigo.config.calibration.image_height_pixels,\n", + ")\n", + "actual_format, expected_format" + ] + }, + { + "cell_type": "markdown", + "id": "home-note", + "metadata": {}, + "source": [ + "### Re-home the mechanisms\n", + "\n", + "Home Z first for vertical clearance, then X and Y. Each linear routine checks encoder response, negative-limit activation and release, datum establishment, controller-mode restoration, and final encoder arrival. Filter homing uses its encoder index and physical opto tab." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "home", + "metadata": {}, + "outputs": [], + "source": [ + "home_positions = {\n", + " \"z\": await celigo.z_axis.home(),\n", + " \"x\": await celigo.x_axis.home(),\n", + " \"y\": await celigo.y_axis.home(),\n", + " \"filter\": await celigo.dichroic_filter.home(),\n", + "}\n", + "home_positions" + ] + }, + { + "cell_type": "markdown", + "id": "open-note", + "metadata": {}, + "source": [ + "## Load the sample\n", + "\n", + "### Open the drawer\n", + "\n", + "This retracts Z, moves to Y clearance, and drives X/Y to their configured loading limits. Keep hands clear until it finishes. Repeating `open_drawer()` is safe because active destination limits are checked." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "open-drawer", + "metadata": {}, + "outputs": [], + "source": [ + "await celigo.open_drawer()" + ] + }, + { + "cell_type": "markdown", + "id": "load-plate", + "metadata": {}, + "source": [ + "### Seat the plate\n", + "\n", + "Place the Corning 3603 plate in the carrier in the instrument's expected orientation. Confirm it is seated flat, then keep clear before running the next cell." + ] + }, + { + "cell_type": "markdown", + "id": "close-note", + "metadata": {}, + "source": [ + "### Close the drawer to A1\n", + "\n", + "The return position is derived from the copied calibration, hardware defaults, and assigned PyLabRobot plate resource. For a custom carrier without a `Plate`, use sample-relative millimeters instead: `await celigo.close_drawer_to_sample_mm(x_mm=63.5, y_mm=43.0)`. Both methods turn off illumination, retract Z, and move through Y clearance before positioning the sample." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "close-drawer", + "metadata": {}, + "outputs": [], + "source": [ + "await celigo.close_drawer(well=\"A1\")" + ] + }, + { + "cell_type": "markdown", + "id": "camera-settings-note", + "metadata": {}, + "source": [ + "## Acquire images\n", + "\n", + "### Set a conservative brightfield exposure\n", + "\n", + "The live camera retained settings across reopen. One millisecond at gain 1 was unsaturated during the first hardware check; tune this for the sample." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "camera-settings", + "metadata": {}, + "outputs": [], + "source": [ + "await celigo.set_camera_exposure_and_gain(\n", + " exposure_ms=1.0,\n", + " gain=1.0,\n", + " restart_camera_stream=True,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "brightfield-note", + "metadata": {}, + "source": [ + "### Acquire one calibrated brightfield image\n", + "\n", + "This single call moves to A1, selects the configured brightfield filter and illumination, centers the calibrated galvos, moves Z to the installed brightfield plane, and captures a geometry-checked frame." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "brightfield", + "metadata": {}, + "outputs": [], + "source": [ + "result = await celigo.acquire(\n", + " \"A1\",\n", + " \"brightfield\",\n", + " exposure_ms=1.0,\n", + " gain=1.0,\n", + ")\n", + "result.z_mm, result.galvo_hardware_voltages" + ] + }, + { + "cell_type": "markdown", + "id": "auto-exposure-note", + "metadata": {}, + "source": [ + "### Let the instrument choose an exposure\n", + "\n", + "Set `machine_auto_exposure=True` when the sample brightness is unknown. The driver tests bounded exposure candidates and returns the selected exposure in the frame metadata." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "auto-exposure", + "metadata": {}, + "outputs": [], + "source": [ + "auto_exposed = await celigo.acquire(\n", + " \"A1\",\n", + " \"brightfield\",\n", + " gain=1.0,\n", + " machine_auto_exposure=True,\n", + ")\n", + "auto_exposed.frame.exposure_ms, auto_exposed.frame.statistics()" + ] + }, + { + "cell_type": "markdown", + "id": "raw-capture-note", + "metadata": {}, + "source": [ + "### Save and inspect the picture\n", + "\n", + "PGM preserves the monochrome pixels without adding an image dependency." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "raw-capture", + "metadata": {}, + "outputs": [], + "source": [ + "result.frame.save_pgm(\"A1-brightfield.pgm\")\n", + "result.frame.statistics(), result.frame.sharpness(sample_step=8)" + ] + }, + { + "cell_type": "markdown", + "id": "focus-stack-note", + "metadata": {}, + "source": [ + "### Run image autofocus when the sample has visible structure\n", + "\n", + "Image autofocus scans around the calibrated channel-specific Z plane, scores each frame, and leaves Z at the best plane. It fails closed when there is no measurable contrast or the optimum lies at the scan boundary." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "focus-stack", + "metadata": {}, + "outputs": [], + "source": [ + "focused = await celigo.acquire(\n", + " \"A1\",\n", + " \"brightfield\",\n", + " exposure_ms=1.0,\n", + " gain=1.0,\n", + " autofocus=\"image\",\n", + ")\n", + "focused.frame.save_pgm(\"A1-brightfield-focused.pgm\")\n", + "focused.focus.z_mm, focused.focus.score" + ] + }, + { + "cell_type": "markdown", + "id": "calibrated-acquisition-gap", + "metadata": {}, + "source": [ + "### Change filters and acquire another channel\n", + "\n", + "The channel name selects the installed dichroic position, illumination output and intensity, galvo offsets, and calibrated Z correction. For example:\n", + "\n", + "```python\n", + "result = await celigo.acquire(\n", + " \"A1\",\n", + " \"green\",\n", + " exposure_ms=1.0,\n", + " gain=1.0,\n", + " autofocus=\"image\",\n", + ")\n", + "result.frame.save_pgm(\"A1-green.pgm\")\n", + "```\n", + "\n", + "Available installed names are `brightfield`, `green`, `red`, `blue`, and `far_red`. All five channel-control and acquisition paths have been exercised. Because that test did not use a fluorescent reference sample, begin with conservative exposure and use `require_lamp_ready=True` when the instrument has a switchable fluorescence lamp." + ] + }, + { + "cell_type": "markdown", + "id": "well-scan-note", + "metadata": {}, + "source": [ + "### Scan named wells\n", + "\n", + "`scan_wells()` is the simple path for one capture at each requested well. `block_shape=(1, 1)` captures the centered field only." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "well-scan", + "metadata": {}, + "outputs": [], + "source": [ + "scan_result = await celigo.scan_wells(\n", + " plate,\n", + " [\"A1\", \"B2\"],\n", + " channel=\"brightfield\",\n", + " block_shape=(1, 1),\n", + " exposure_ms=1.0,\n", + " gain=1.0,\n", + ")\n", + "[(item.planned.block.label, item.frame.statistics()) for item in scan_result.frames]" + ] + }, + { + "cell_type": "markdown", + "id": "trigger-diagnostics-note", + "metadata": {}, + "source": [ + "## Inspect and stop\n", + "\n", + "### Read camera-trigger diagnostics\n", + "\n", + "These methods inspect the controller's camera synchronization lines. A result of `None` means that this firmware does not expose that input; on the tested controller the integration input reports `None`. This verifies diagnostics, not externally triggered frame acquisition." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "trigger-diagnostics", + "metadata": {}, + "outputs": [], + "source": [ + "camera_signals = {\n", + " \"busy\": await celigo.request_is_camera_busy(),\n", + " \"integrating\": await celigo.request_is_camera_integrating(),\n", + " \"trigger_encoder_ticks\": await celigo.request_camera_trigger_encoder_ticks(),\n", + "}\n", + "camera_signals" + ] + }, + { + "cell_type": "markdown", + "id": "teardown-note", + "metadata": {}, + "source": [ + "### Stop safely\n", + "\n", + "Always run this cell, including after an exception. `stop()` aborts controller work, clears analog and digital illumination outputs, closes the camera, and releases FTDI." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "teardown", + "metadata": {}, + "outputs": [], + "source": [ + "await celigo.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/revvity/celigo/index.md b/docs/user_guide/revvity/celigo/index.md new file mode 100644 index 00000000000..3ef7e349d1c --- /dev/null +++ b/docs/user_guide/revvity/celigo/index.md @@ -0,0 +1,25 @@ +# Celigo + +```{toctree} +:maxdepth: 1 + +hello-world +advanced-imaging +scan-planning +components-and-diagnostics +``` + +[Product page](https://www.revvity.com/product/celigo-5c-config-200-bffl-5c) + +PyLabRobot controls the Celigo directly through its FTDI controller and Lumenera camera. +Instrument setup, safety boundaries, physical scan planning, imaging, and diagnostics +are covered by the notebooks above. + +[Celigo Hello World](hello-world.ipynb) introduces configuration, setup, homing, drawer +motion, channels, basic acquisition, and the one-call well scan. [Advanced +Imaging](advanced-imaging.ipynb) covers coordinate transforms, exposure, autofocus, +structured results, multichannel capture, and calibrated galvo imaging. [Physical Scan +Planning](scan-planning.ipynb) covers reusable scan specifications, arbitrary block +shapes, physical points and bounds, estimates, and inspected execution. [Hardware Components and +Diagnostics](components-and-diagnostics.ipynb) covers low-level components, controller +I/O, active self-tests, and laser safety. diff --git a/docs/user_guide/revvity/celigo/scan-planning.ipynb b/docs/user_guide/revvity/celigo/scan-planning.ipynb new file mode 100644 index 00000000000..b8f048f0193 --- /dev/null +++ b/docs/user_guide/revvity/celigo/scan-planning.ipynb @@ -0,0 +1,320 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title", + "metadata": {}, + "source": [ + "# Scan specifications and planning\n", + "\n", + "A `ScanSpec` contains geometry, captures, and autofocus policy. `celigo.plan(spec)` compiles it offline; `await celigo.execute(plan)` performs the inspected operations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "imports", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "from pylabrobot.resources.corning.plates import cor_96_wellplate_360uL_Fb\n", + "from pylabrobot.revvity import (\n", + " Capture,\n", + " Celigo,\n", + " CeligoConfig,\n", + " ScanEstimateModel,\n", + " ScanRegion,\n", + " ScanSpec,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "configure-note", + "metadata": {}, + "source": [ + "## Configure without connecting\n", + "\n", + "Planning reads the installed calibration but does not connect to or move the instrument." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "configure", + "metadata": {}, + "outputs": [], + "source": [ + "config_root = Path(\"/path/to/Celigo/ConfigFiles\")\n", + "config = CeligoConfig.from_install(str(config_root))\n", + "celigo = Celigo(config=config)" + ] + }, + { + "cell_type": "markdown", + "id": "coverage-note", + "metadata": {}, + "source": [ + "## Cover physical bounds\n", + "\n", + "`full_coverage()` chooses the frame grid and groups it into coarse-stage blocks that fit the calibrated galvo reach." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "coverage", + "metadata": {}, + "outputs": [], + "source": [ + "region = ScanRegion.from_bounds_mm(left=5, top=5, right=122, bottom=81)\n", + "coverage_spec = ScanSpec.full_coverage(\n", + " region,\n", + " channel=\"brightfield\",\n", + " exposure_ms=1.0,\n", + ")\n", + "coverage_plan = celigo.plan(coverage_spec)\n", + "print(coverage_plan)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "inspect", + "metadata": {}, + "outputs": [], + "source": [ + "first_block = coverage_plan.blocks[0]\n", + "first_frame = coverage_plan.frames[0]\n", + "{\n", + " \"block_stage_mm\": (first_block.stage_x_mm, first_block.stage_y_mm),\n", + " \"block_shape\": first_block.block_shape,\n", + " \"frame_sample_mm\": (\n", + " first_frame.position.sample_x_mm,\n", + " first_frame.position.sample_y_mm,\n", + " ),\n", + " \"channel\": first_frame.capture.channel,\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "points-note", + "metadata": {}, + "source": [ + "## Scan physical points\n", + "\n", + "Points need no enclosing bounds or labels. `block_shape=(columns, rows)` may be any positive shape within the calibrated per-stage reach." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "points", + "metadata": {}, + "outputs": [], + "source": [ + "point_spec = ScanSpec.points(\n", + " [(25.0, 20.0), (63.5, 43.0), (102.0, 66.0)],\n", + " block_shape=(2, 3),\n", + " channel=\"brightfield\",\n", + ")\n", + "point_plan = celigo.plan(point_spec)\n", + "[(block.center_x_mm, block.center_y_mm) for block in point_plan.blocks]" + ] + }, + { + "cell_type": "markdown", + "id": "random-note", + "metadata": {}, + "source": [ + "## Sample reproducible random blocks\n", + "\n", + "The seed fixes the chosen physical blocks. Non-overlapping blocks are the default." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "random", + "metadata": {}, + "outputs": [], + "source": [ + "random_spec = ScanSpec.random(\n", + " region,\n", + " count=10,\n", + " block_shape=(4, 4),\n", + " seed=42,\n", + " channel=\"brightfield\",\n", + ")\n", + "random_plan = celigo.plan(random_spec)\n", + "random_plan.stage_positions_mm" + ] + }, + { + "cell_type": "markdown", + "id": "wells-note", + "metadata": {}, + "source": [ + "## Scan named wells\n", + "\n", + "`ScanSpec.wells()` converts names once to labeled physical centers. The resulting specification does not retain the plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "wells", + "metadata": {}, + "outputs": [], + "source": [ + "plate = cor_96_wellplate_360uL_Fb(name=\"imaging_plate\")\n", + "well_spec = ScanSpec.wells(\n", + " plate,\n", + " [\"A1\", \"B2\", \"C3\"],\n", + " block_shape=(2, 3),\n", + " channel=\"brightfield\",\n", + " exposure_ms=1.0,\n", + " gain=1.0,\n", + " autofocus=\"image\",\n", + ")\n", + "well_plan = celigo.plan(well_spec)\n", + "[(block.label, block.block_shape) for block in well_plan.blocks]" + ] + }, + { + "cell_type": "markdown", + "id": "one-call-note", + "metadata": {}, + "source": [ + "For the common single-capture case, skip the explicit specification:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "one-call", + "metadata": {}, + "outputs": [], + "source": [ + "async def scan_a1_and_b2():\n", + " return await celigo.scan_wells(\n", + " plate,\n", + " [\"A1\", \"B2\"],\n", + " channel=\"brightfield\",\n", + " block_shape=(1, 1),\n", + " exposure_ms=1.0,\n", + " gain=1.0,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "captures-note", + "metadata": {}, + "source": [ + "## Plan multiple captures\n", + "\n", + "Use `Capture` when every position needs more than one channel. Capture settings become part of the plan, and the coarse stage moves once per block." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "captures", + "metadata": {}, + "outputs": [], + "source": [ + "multichannel_spec = ScanSpec.wells(\n", + " plate,\n", + " [\"A1\", \"B2\"],\n", + " block_shape=(2, 3),\n", + " captures=[\n", + " Capture(channel=\"brightfield\", exposure_ms=1.0, gain=1.0),\n", + " Capture(channel=\"green\", exposure_ms=20.0, gain=2.0),\n", + " ],\n", + " autofocus=\"image\",\n", + ")\n", + "multichannel_plan = celigo.plan(multichannel_spec)\n", + "print(multichannel_plan)" + ] + }, + { + "cell_type": "markdown", + "id": "estimates-note", + "metadata": {}, + "source": [ + "## Use measured throughput\n", + "\n", + "Exposure times come from the captures. Supply only instrument overhead and storage assumptions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "estimates", + "metadata": {}, + "outputs": [], + "source": [ + "estimate_model = ScanEstimateModel(\n", + " seconds_per_frame=0.35,\n", + " seconds_per_stage_position=2.0,\n", + " seconds_per_autofocus=5.0,\n", + " bytes_per_pixel=2,\n", + ")\n", + "estimated_plan = celigo.plan(multichannel_spec, estimate_model=estimate_model)\n", + "estimated_plan.estimated_duration, estimated_plan.estimated_storage_bytes" + ] + }, + { + "cell_type": "markdown", + "id": "execute-note", + "metadata": {}, + "source": [ + "## Execute an inspected plan\n", + "\n", + "The guarded cell below is the only hardware operation in this notebook. `execute()` accepts no geometry or capture overrides." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "execute", + "metadata": {}, + "outputs": [], + "source": [ + "RUN_HARDWARE = False\n", + "\n", + "if RUN_HARDWARE:\n", + " await celigo.setup()\n", + " try:\n", + " scan_result = await celigo.execute(multichannel_plan)\n", + " finally:\n", + " await celigo.stop()\n", + "\n", + " first_result = scan_result.frames[0]\n", + " print(\n", + " first_result.planned.block.label,\n", + " first_result.planned.capture.channel,\n", + " first_result.actual_stage_mm,\n", + " first_result.actual_z_mm,\n", + " )" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv (3.11.15)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.15" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/revvity/index.md b/docs/user_guide/revvity/index.md new file mode 100644 index 00000000000..b929d6c12c0 --- /dev/null +++ b/docs/user_guide/revvity/index.md @@ -0,0 +1,12 @@ +# Revvity + +```{toctree} +:maxdepth: 1 + +celigo/index +``` + +PyLabRobot supports the following Revvity instruments: + +- [Celigo](celigo/index.md), an image cytometer with brightfield and fluorescence + imaging, autofocus, auto-exposure, and multi-field scanning. diff --git a/pylabrobot/io/ftdi.py b/pylabrobot/io/ftdi.py index 3b4fd2eaa9b..b07470c302d 100644 --- a/pylabrobot/io/ftdi.py +++ b/pylabrobot/io/ftdi.py @@ -3,7 +3,7 @@ import logging from concurrent.futures import ThreadPoolExecutor from io import IOBase -from typing import Optional, cast +from typing import Optional, Tuple, cast from pylabrobot.events import emit_event @@ -11,6 +11,21 @@ import pylibftdi.driver from pylibftdi import Device, FtdiError + class _USBAddressDevice(Device): + """pylibftdi device opened by libusb bus and device address.""" + + def __init__(self, usb_bus: int, usb_device_address: int, **kwargs): + self._usb_bus = usb_bus + self._usb_device_address = usb_device_address + super().__init__(**kwargs) + + def _open_device(self) -> int: + return int( + self.fdll.ftdi_usb_open_bus_addr( + ctypes.byref(self.ctx), self._usb_bus, self._usb_device_address + ) + ) + HAS_PYLIBFTDI = True except ImportError as e: HAS_PYLIBFTDI = False @@ -32,6 +47,19 @@ logger = logging.getLogger(__name__) +def _parse_usb_address(address: str) -> Tuple[int, Tuple[int, ...]]: + """Parse a USB topology path '-[....]' into its bus and ports.""" + bus_str, sep, port_str = address.partition("-") + if not sep: + raise ValueError(f"USB address must be '-[....]', got {address!r}") + try: + bus = int(bus_str) + ports = tuple(int(port) for port in port_str.split(".")) if port_str else () + except ValueError as exc: + raise ValueError(f"Invalid USB address {address!r}: {exc}") from exc + return bus, ports + + class FTDICommand(Command): data: str @@ -63,6 +91,7 @@ def __init__( vid: Optional[int] = None, pid: Optional[int] = None, interface_select: Optional[int] = None, + usb_address: Optional[str] = None, ): if not HAS_PYLIBFTDI: global _FTDI_ERROR @@ -82,6 +111,7 @@ def __init__( self._vid = vid self._pid = pid self._interface_select = interface_select + self._usb_address = usb_address # Will be resolved in setup() self._dev: Optional[Device] = None @@ -89,6 +119,7 @@ def __init__( # Bytes off the wire that no read has taken yet, and the read still in flight, if any. self._unread = bytearray() self._pending_read: Optional["asyncio.Future"] = None + self._detached_kernel_driver: Optional[Tuple[int, int, int]] = None if get_capture_or_validation_active(): raise RuntimeError( @@ -184,23 +215,108 @@ def _resolve_device_serial(self) -> str: device_serial_number = cast(str, usb.util.get_string(device, device.iSerialNumber)) return device_serial_number + def _resolve_device_location(self) -> Tuple[int, int]: + """Resolve a topology path to the exact libusb bus/device-address pair.""" + if self._vid is None or self._pid is None: + raise RuntimeError("usb_address requires both vid and pid to be specified.") + assert self._usb_address is not None + bus, ports = _parse_usb_address(self._usb_address) + for device in usb.core.find(find_all=True, idVendor=self._vid, idProduct=self._pid): + try: + device_ports = tuple(device.port_numbers) if device.port_numbers else () + except (ValueError, NotImplementedError): + continue + if device.bus == bus and device_ports == ports: + if device.address is None: + raise RuntimeError(f"USB device at {self._usb_address!r} has no device address") + return int(device.bus), int(device.address) + raise RuntimeError( + f"No device with VID:PID {self._vid:04x}:{self._pid:04x} found at USB path " + f"{self._usb_address!r}." + ) + + @staticmethod + def _usb_device_at(bus: int, address: int): + return next( + ( + device + for device in usb.core.find(find_all=True) + if device.bus == bus and device.address == address + ), + None, + ) + + def _detach_kernel_driver(self, bus: int, address: int) -> None: + """Release a topology-selected FTDI before pylibftdi tries to claim it.""" + interface = max(0, (self._interface_select or 1) - 1) + device = self._usb_device_at(bus, address) + if device is None: + raise RuntimeError(f"USB device at bus {bus}, address {address} disappeared before open") + try: + try: + kernel_driver_active = device.is_kernel_driver_active(interface) + except NotImplementedError: + logger.debug("USB backend does not support inspecting kernel-driver state") + return + if kernel_driver_active: + try: + device.detach_kernel_driver(interface) + except NotImplementedError: + logger.debug("USB backend does not support detaching kernel drivers") + return + self._detached_kernel_driver = (bus, address, interface) + finally: + usb.util.dispose_resources(device) + + def _reattach_kernel_driver(self) -> None: + detached = getattr(self, "_detached_kernel_driver", None) + self._detached_kernel_driver = None + if detached is None: + return + bus, address, interface = detached + device = self._usb_device_at(bus, address) + if device is None: + logger.warning( + "Could not reattach the kernel driver: USB device at bus %s, address %s disappeared", + bus, + address, + ) + return + try: + if not device.is_kernel_driver_active(interface): + device.attach_kernel_driver(interface) + except (NotImplementedError, usb.core.USBError) as exc: + logger.warning("Could not reattach the FTDI kernel driver: %s", exc) + finally: + usb.util.dispose_resources(device) + def _setup_sync(self) -> None: """Resolve and open the device. Runs on the executor that owns all device calls.""" if self._dev is not None and not self._dev.closed: self._dev.close() self._dev = None - # Resolve which device to connect to - self._device_id = self._resolve_device_serial() - - # Create and open device - dev = Device( - lazy_open=True, - device_id=self.device_id, - pid=self._pid, - vid=self._vid, - interface_select=self._interface_select, - ) + if self._usb_address is not None: + usb_bus, usb_device_address = self._resolve_device_location() + self._detach_kernel_driver(usb_bus, usb_device_address) + self._device_id = self._usb_address + dev = _USBAddressDevice( + lazy_open=True, + usb_bus=usb_bus, + usb_device_address=usb_device_address, + pid=self._pid, + vid=self._vid, + interface_select=self._interface_select, + ) + else: + self._device_id = self._resolve_device_serial() + dev = Device( + lazy_open=True, + device_id=self.device_id, + pid=self._pid, + vid=self._vid, + interface_select=self._interface_select, + ) try: dev.open() except BaseException: @@ -208,6 +324,7 @@ def _setup_sync(self) -> None: dev.close() except Exception: logger.warning("Failed to close FTDI device after setup failure", exc_info=True) + self._reattach_kernel_driver() raise self._dev = dev @@ -231,6 +348,7 @@ async def setup(self): except Exception: logger.warning("Failed to close FTDI device after setup failure", exc_info=True) self._dev = None + await loop.run_in_executor(self._executor, self._reattach_kernel_driver) self._shutdown_executor() if isinstance(exc, FtdiError): raise RuntimeError( @@ -342,11 +460,45 @@ async def request_serial(self) -> str: async def stop(self): loop = asyncio.get_running_loop() - if self._dev is not None: - await loop.run_in_executor(self._executor, self.dev.close) - self._dev = None - self._shutdown_executor() - self._discard_reads() + executor = self._executor + first_error: Optional[BaseException] = None + + async def attempt(operation) -> None: + nonlocal first_error + future = loop.run_in_executor(executor, operation) + try: + await asyncio.shield(future) + except asyncio.CancelledError as exc: + try: + await future + except Exception: + logger.warning("FTDI shutdown operation failed after cancellation", exc_info=True) + if first_error is None: + first_error = exc + except BaseException as exc: + if first_error is None: + first_error = exc + else: + logger.warning("Additional FTDI shutdown operation failed", exc_info=True) + + try: + if self._dev is not None: + await attempt(self.dev.close) + self._dev = None + if getattr(self, "_detached_kernel_driver", None) is not None: + await attempt(self._reattach_kernel_driver) + finally: + try: + self._shutdown_executor() + except BaseException as exc: + if first_error is None: + first_error = exc + else: + logger.warning("FTDI executor shutdown failed", exc_info=True) + finally: + self._discard_reads() + if first_error is not None: + raise first_error def _discard_reads(self) -> None: """Drop read data buffered here and in flight, once the caller has declared it stale.""" @@ -463,6 +615,7 @@ def serialize(self): "device_id": self._device_id, "vid": self._vid, "pid": self._pid, + "usb_address": self._usb_address, } diff --git a/pylabrobot/io/ftdi_tests.py b/pylabrobot/io/ftdi_tests.py index 6909a11e0ca..c0f962e87c2 100644 --- a/pylabrobot/io/ftdi_tests.py +++ b/pylabrobot/io/ftdi_tests.py @@ -1,10 +1,12 @@ import asyncio import logging import tempfile +import threading import time import unittest from concurrent.futures import ThreadPoolExecutor from pathlib import Path +from types import SimpleNamespace from typing import Any, List, Union from unittest import mock @@ -191,6 +193,106 @@ async def reader(name: str) -> None: self.assertEqual(received["second"], [b"", b"", b"", b""]) +class FTDITopologyLifecycleTests(unittest.IsolatedAsyncioTestCase): + """Topology selection remains portable and always releases its host resources.""" + + @staticmethod + def _ftdi_without_optional_dependency_checks() -> FTDI: + io = FTDI.__new__(FTDI) + io._interface_select = None + io._detached_kernel_driver = None + io._dev = None + io._executor = None + io._unread = bytearray() + io._pending_read = None + return io + + async def test_detach_tolerates_a_backend_without_kernel_driver_support(self) -> None: + io = self._ftdi_without_optional_dependency_checks() + device = mock.Mock() + device.is_kernel_driver_active.side_effect = NotImplementedError + dispose_resources = mock.Mock() + usb_module = SimpleNamespace(util=SimpleNamespace(dispose_resources=dispose_resources)) + + with ( + mock.patch.object(ftdi_module, "usb", usb_module, create=True), + mock.patch.object(io, "_usb_device_at", return_value=device), + ): + io._detach_kernel_driver(3, 17) + + self.assertIsNone(io._detached_kernel_driver) + device.detach_kernel_driver.assert_not_called() + dispose_resources.assert_called_once_with(device) + + async def test_detach_tolerates_an_unsupported_detach_operation(self) -> None: + io = self._ftdi_without_optional_dependency_checks() + device = mock.Mock() + device.is_kernel_driver_active.return_value = True + device.detach_kernel_driver.side_effect = NotImplementedError + dispose_resources = mock.Mock() + usb_module = SimpleNamespace(util=SimpleNamespace(dispose_resources=dispose_resources)) + + with ( + mock.patch.object(ftdi_module, "usb", usb_module, create=True), + mock.patch.object(io, "_usb_device_at", return_value=device), + ): + io._detach_kernel_driver(3, 17) + + self.assertIsNone(io._detached_kernel_driver) + device.detach_kernel_driver.assert_called_once_with(0) + dispose_resources.assert_called_once_with(device) + + async def test_stop_reattaches_and_shuts_down_after_close_failure(self) -> None: + io = self._ftdi_without_optional_dependency_checks() + device = mock.Mock() + device.close.side_effect = RuntimeError("simulated close failure") + io._dev = device + io._executor = ThreadPoolExecutor(max_workers=1) + io._detached_kernel_driver = (3, 17, 0) + + with ( + mock.patch.object(io, "_reattach_kernel_driver") as reattach, + self.assertRaisesRegex(RuntimeError, "close failure"), + ): + await io.stop() + + reattach.assert_called_once_with() + self.assertIsNone(io._dev) + self.assertIsNone(io._executor) + + async def test_cancelled_stop_waits_for_close_and_reattaches(self) -> None: + io = self._ftdi_without_optional_dependency_checks() + close_started = threading.Event() + + def close() -> None: + close_started.set() + time.sleep(0.05) + + io._dev = SimpleNamespace(close=close) + io._executor = ThreadPoolExecutor(max_workers=1) + io._detached_kernel_driver = (3, 17, 0) + + with mock.patch.object(io, "_reattach_kernel_driver") as reattach: + stop_task = asyncio.create_task(io.stop()) + for _ in range(100): + if close_started.is_set(): + break + await asyncio.sleep(0.001) + self.assertTrue(close_started.is_set()) + stop_task.cancel() + for _ in range(200): + if stop_task.done(): + break + await asyncio.sleep(0.001) + self.assertTrue(stop_task.done()) + with self.assertRaises(asyncio.CancelledError): + await stop_task + + reattach.assert_called_once_with() + self.assertIsNone(io._dev) + self.assertIsNone(io._executor) + + @unittest.skipUnless(HAS_PYLIBFTDI and HAS_PYUSB, "pylibftdi/pyusb not installed") class FTDIEventTests(unittest.IsolatedAsyncioTestCase): """`read` now returns from a buffer and `write` is shielded, so both `emit_event` sites moved.""" diff --git a/pylabrobot/revvity/__init__.py b/pylabrobot/revvity/__init__.py new file mode 100644 index 00000000000..dcf46c6846e --- /dev/null +++ b/pylabrobot/revvity/__init__.py @@ -0,0 +1,37 @@ +"""Revvity instruments.""" + +from pylabrobot.revvity.celigo import ( + AutofocusMethod, + BlockShape, + Capture, + Celigo, + CeligoConfig, + CoordinateMM, + FrameResult, + PlannedFrame, + ScanBlock, + ScanEstimateModel, + ScanPlan, + ScanPosition, + ScanRegion, + ScanResult, + ScanSpec, +) + +__all__ = [ + "AutofocusMethod", + "BlockShape", + "Capture", + "Celigo", + "CeligoConfig", + "CoordinateMM", + "FrameResult", + "PlannedFrame", + "ScanBlock", + "ScanEstimateModel", + "ScanPlan", + "ScanPosition", + "ScanRegion", + "ScanResult", + "ScanSpec", +] diff --git a/pylabrobot/revvity/celigo/README.md b/pylabrobot/revvity/celigo/README.md new file mode 100644 index 00000000000..19f9dcf1dae --- /dev/null +++ b/pylabrobot/revvity/celigo/README.md @@ -0,0 +1,43 @@ +# Celigo + +A direct PyLabRobot driver for the Revvity Celigo image cytometer (formerly sold by +Nexcelom/Cyntellect). `Celigo` is the main entry point and owns its camera, galvo, and +laser components. It talks to the FTDI USB-IO controller without requiring the Celigo +application. + +## Major components + +| Component | Role | +|---|---| +| `Celigo` | Top-level coordinator. Owns connection lifecycle, hardware components, illumination, acquisition, autofocus, scanning, and diagnostics. | +| `CeligoConfig` | Complete per-instrument configuration assembled from the vendor files, including motor limits, optical calibration, navigation geometry, and channel recipes. | +| FTDI transport and `MotorController` | Carry controller-board commands and tunneled EZStepper commands. | +| `LinearAxis`, `Axis`, and `FilterWheel` | Represent the configured stage, focus, and optical mechanisms. `Celigo` constructs and owns these objects from `CeligoConfig`. | +| `CeligoCamera` and `CameraFrame` | Manage the Lumenera camera lifecycle and return calibrated, dependency-free monochrome frames. | +| `Galvo` | Converts sample-relative offsets through the installed optical calibration and positions both galvo axes. | +| `Laser` | Owns the separate laser UART protocol, safety checks, targeting, and firing operations. | +| `CoordinateSystems` and navigation helpers | Convert between pixels, top-left sample millimeters, stage millimeters, plate wells, and galvo field positions. | +| `ScanSpec`, `ScanPlan`, and `ScanResult` | Form the scan pipeline. A specification contains geometry and captures, a plan contains validated hardware operations, and a result links every frame to its planned operation. | +| `AcquisitionResult` and `FocusResult` | Record direct single-field acquisition metadata and autofocus measurements. | + +`Celigo` is the only component that coordinates hardware. Scan specifications and plans +are immutable values; planning uses configuration and coordinate math without connecting +to the instrument. Execution passes the compiled stage, galvo, channel, camera, and +focus operations back through the owning `Celigo`. + +## Package + +| Module | Responsibility | +|---|---| +| `celigo.py` | Device lifecycle, FTDI protocol, illumination, acquisition, autofocus, and diagnostics | +| `motion.py` | Stepper motors, linear axes, filter wheels, homing, and encoder motion | +| `camera.py` | Async Lumenera SDK capture and dependency-free raw image frames | +| `galvo.py` | Galvo positioning, calibration, status, and calibrated voltage conversion | +| `laser.py` | Guarded laser UART commands, firing, galvo targeting, and laser optics | +| `config.py` | Typed loaders for the vendor hardware, optical calibration, and channel configuration | +| `coordinates.py` | Pixel, sample-mm, and stage-mm coordinate frames | +| `navigation.py` | Plate/well navigation and galvo FOV planning | +| `scan.py` | Scan specifications, physical planning, execution results, and offline estimates | + +Tutorials, safety guidance, and hardware workflows live in the +[Celigo user guide](../../../docs/user_guide/revvity/celigo/index.md). diff --git a/pylabrobot/revvity/celigo/__init__.py b/pylabrobot/revvity/celigo/__init__.py new file mode 100644 index 00000000000..c52229a5759 --- /dev/null +++ b/pylabrobot/revvity/celigo/__init__.py @@ -0,0 +1,143 @@ +"""Control for the Revvity Celigo image cytometer. + +The :class:`~pylabrobot.revvity.celigo.celigo.Celigo` class drives the instrument's FTDI USB-IO +controller board: stage/Z/filter motion, drawer open/close, illumination channels +(brightfield + fluorescence), galvo steering, and the board's digital/analog IO and +barcode reader. Its :class:`~pylabrobot.revvity.celigo.laser.Laser` component owns laser +communication and firing operations, while :class:`~pylabrobot.revvity.celigo.galvo.Galvo` +owns galvo positioning and calibration. + +The :mod:`~pylabrobot.revvity.celigo.config`, :mod:`~pylabrobot.revvity.celigo.coordinates`, and +:mod:`~pylabrobot.revvity.celigo.navigation` modules hold the configuration and plate/well +navigation math used by the device. +""" + +from pylabrobot.revvity.celigo.camera import CameraError, CameraFrame, CeligoCamera +from pylabrobot.revvity.celigo.celigo import ( + AcquisitionResult, + Celigo, + ControllerInfo, + ControllerStatus, + DetectedMotorAddress, + FocusResult, + SelfTestReport, +) +from pylabrobot.revvity.celigo.config import ( + AnalogInputConfig, + AxisConfig, + Calibrated2DPolynomialTransform, + CalibrationConfig, + CeligoConfig, + CeligoHardwareConfig, + ChannelDescriptor, + DigitalIOConfig, + ExternalCameraControlConfig, + FilterMapEntry, + FilterWheelConfig, + GalvoAxisOpticalCalibration, + GalvoConfig, + GalvoMagnificationCalibration, + GalvoOpticalCalibration, + HardwareDefaultConfig, + IlluminationChannelConfig, + IOConfig, + LightingIOConfig, + LinearAxisConfig, + NavigationConfig, + load_channel_descriptors, + load_galvo_calibrations, + load_galvo_optical_calibration, + load_illumination_channels, +) +from pylabrobot.revvity.celigo.coordinates import CoordinateSystems +from pylabrobot.revvity.celigo.errors import CeligoError +from pylabrobot.revvity.celigo.galvo import Galvo, GalvoControllerStatus +from pylabrobot.revvity.celigo.laser import Laser +from pylabrobot.revvity.celigo.motion import ( + Axis, + FilterWheel, + LinearAxis, + MagnificationChanger, + MotorController, + StepperMotor, +) +from pylabrobot.revvity.celigo.navigation import well_to_sample_mm, well_to_stage_mm +from pylabrobot.revvity.celigo.scan import ( + AutofocusMethod, + BlockShape, + Capture, + CoordinateMM, + FrameResult, + PlannedFrame, + ScanBlock, + ScanEstimateModel, + ScanPlan, + ScanPosition, + ScanRegion, + ScanResult, + ScanSpec, +) + +__all__ = [ + "AcquisitionResult", + "AnalogInputConfig", + "AutofocusMethod", + "Axis", + "AxisConfig", + "BlockShape", + "Calibrated2DPolynomialTransform", + "CalibrationConfig", + "CameraError", + "CameraFrame", + "Capture", + "Celigo", + "CeligoCamera", + "CeligoConfig", + "CeligoError", + "CeligoHardwareConfig", + "ChannelDescriptor", + "ControllerInfo", + "ControllerStatus", + "CoordinateMM", + "CoordinateSystems", + "DetectedMotorAddress", + "DigitalIOConfig", + "ExternalCameraControlConfig", + "FilterMapEntry", + "FilterWheel", + "FilterWheelConfig", + "FocusResult", + "FrameResult", + "Galvo", + "GalvoAxisOpticalCalibration", + "GalvoConfig", + "GalvoControllerStatus", + "GalvoMagnificationCalibration", + "GalvoOpticalCalibration", + "HardwareDefaultConfig", + "IOConfig", + "IlluminationChannelConfig", + "Laser", + "LightingIOConfig", + "LinearAxis", + "LinearAxisConfig", + "MagnificationChanger", + "MotorController", + "NavigationConfig", + "PlannedFrame", + "ScanBlock", + "ScanEstimateModel", + "ScanPlan", + "ScanPosition", + "ScanRegion", + "ScanResult", + "ScanSpec", + "SelfTestReport", + "StepperMotor", + "load_channel_descriptors", + "load_galvo_calibrations", + "load_galvo_optical_calibration", + "load_illumination_channels", + "well_to_sample_mm", + "well_to_stage_mm", +] diff --git a/pylabrobot/revvity/celigo/camera.py b/pylabrobot/revvity/celigo/camera.py new file mode 100644 index 00000000000..4d5baba58eb --- /dev/null +++ b/pylabrobot/revvity/celigo/camera.py @@ -0,0 +1,701 @@ +"""Async wrapper for the Celigo's Lumenera camera (``liblucamapi``). + +The SDK calls are blocking, so public methods run them in worker threads. Raw image +capture has no third-party Python dependency; :meth:`CameraFrame.to_numpy` requires +NumPy only when requested. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import ctypes +import ctypes.util +import functools +import os +import queue +import struct +import sys +import threading +import time +import zlib +from array import array +from dataclasses import dataclass +from typing import Any, Callable, Optional, Protocol, Tuple, TypeVar + +_T = TypeVar("_T") + + +class _SerializedDaemonExecutor: + """One serialized daemon worker for native calls that Python cannot cancel.""" + + def __init__(self): + self._queue: "queue.Queue[Optional[tuple[concurrent.futures.Future[Any], Callable[[], Any]]]]" = queue.Queue() + self._closed = False + self._thread = threading.Thread(target=self._run, name="celigo-camera", daemon=True) + self._thread.start() + + def submit(self, function: Callable[[], _T]) -> "concurrent.futures.Future[_T]": + if self._closed: + raise RuntimeError("camera executor is shut down") + future: "concurrent.futures.Future[_T]" = concurrent.futures.Future() + self._queue.put((future, function)) + return future + + def shutdown(self) -> None: + if not self._closed: + self._closed = True + self._queue.put(None) + + def _run(self) -> None: + while True: + work = self._queue.get() + if work is None: + return + future, function = work + if not future.set_running_or_notify_cancel(): + continue + try: + future.set_result(function()) + except BaseException as exc: + future.set_exception(exc) + + +LUCAM_PROP_EXPOSURE = 20 +LUCAM_PROP_GAIN = 40 +LUCAM_PF_8 = 0 +LUCAM_PF_16 = 1 +_START_STREAMING = 1 +_STOP_STREAMING = 0 + + +class CameraError(RuntimeError): + """A Lumenera SDK operation failed.""" + + +class _LucamFrameFormat(ctypes.Structure): + _fields_ = [ + ("x_offset", ctypes.c_uint32), + ("y_offset", ctypes.c_uint32), + ("width", ctypes.c_uint32), + ("height", ctypes.c_uint32), + ("pixel_format", ctypes.c_uint32), + ("subsample_x", ctypes.c_uint16), + ("flags_x", ctypes.c_uint16), + ("subsample_y", ctypes.c_uint16), + ("flags_y", ctypes.c_uint16), + ] + + +@dataclass(frozen=True) +class CameraFrame: + """One raw monochrome camera frame and its acquisition metadata.""" + + data: bytes + width: int + height: int + bit_depth: int + exposure_ms: float + gain: float + captured_at: float + + @property + def pixel_count(self) -> int: + return self.width * self.height + + def pixels(self) -> array: + """Return pixels as a standard-library array (native-endian for 16-bit frames).""" + values = array("H" if self.bit_depth > 8 else "B") + values.frombytes(self.data) + return values + + def statistics(self) -> Tuple[int, int, float]: + """Return ``(minimum, maximum, mean)`` without requiring NumPy.""" + values = self.pixels() + if not values: + raise CameraError("Camera returned an empty image") + return min(values), max(values), sum(values) / len(values) + + def sharpness(self, sample_step: int = 2) -> float: + """Variance of a Laplacian over the central image region. + + ``sample_step`` reduces work on large sensors. + """ + if sample_step < 1: + raise ValueError("sample_step must be at least 1") + values = self.pixels() + width, height = self.width, self.height + if width < 3 or height < 3: + return 0.0 + x0, x1 = width // 4, width - width // 4 + y0, y1 = height // 4, height - height // 4 + total = 0.0 + total_squared = 0.0 + count = 0 + for y in range(max(1, y0), min(height - 1, y1), sample_step): + row = y * width + for x in range(max(1, x0), min(width - 1, x1), sample_step): + center = row + x + laplacian = ( + values[center - 1] + + values[center + 1] + + values[center - width] + + values[center + width] + - 4 * values[center] + ) + total += laplacian + total_squared += laplacian * laplacian + count += 1 + if count == 0: + return 0.0 + mean = total / count + return total_squared / count - mean * mean + + def to_numpy(self): + """Return a ``height x width`` NumPy view of the frame.""" + try: + import numpy as np # type: ignore + except ImportError as exc: + raise RuntimeError("CameraFrame.to_numpy() requires numpy") from exc + dtype = np.uint16 if self.bit_depth > 8 else np.uint8 + return np.frombuffer(self.data, dtype=dtype).reshape(self.height, self.width) + + def save_pgm(self, path: str) -> None: + """Save the raw frame as a portable graymap image.""" + body = self.data + if self.bit_depth > 8 and sys.byteorder == "little": + values = self.pixels() + values.byteswap() + body = values.tobytes() + maximum = 65535 if self.bit_depth > 8 else 255 + with open(path, "wb") as output: + output.write(f"P5\n{self.width} {self.height}\n{maximum}\n".encode("ascii")) + output.write(body) + + def to_png_bytes(self, maximum_size: Optional[int] = None) -> bytes: + """Encode the frame as a PNG, optionally downsampling it to a bounding square.""" + if maximum_size is not None and ( + isinstance(maximum_size, bool) or not isinstance(maximum_size, int) or maximum_size <= 0 + ): + raise ValueError("maximum_size must be a positive integer") + bytes_per_pixel = 2 if self.bit_depth > 8 else 1 + expected_bytes = self.pixel_count * bytes_per_pixel + if len(self.data) != expected_bytes: + raise CameraError( + f"Frame contains {len(self.data)} bytes; expected {expected_bytes} for " + f"{self.width}x{self.height} at {self.bit_depth}-bit" + ) + body = self.data + if self.bit_depth > 8 and sys.byteorder == "little": + values = self.pixels() + values.byteswap() + body = values.tobytes() + output_width = self.width + output_height = self.height + if maximum_size is not None and max(self.width, self.height) > maximum_size: + scale = maximum_size / max(self.width, self.height) + output_width = max(1, round(self.width * scale)) + output_height = max(1, round(self.height * scale)) + source_stride = self.width * bytes_per_pixel + rows = [] + for output_y in range(output_height): + source_y = min(self.height - 1, output_y * self.height // output_height) + source_row = body[source_y * source_stride : (source_y + 1) * source_stride] + rows.append( + b"".join( + source_row[source_x * bytes_per_pixel : (source_x + 1) * bytes_per_pixel] + for source_x in ( + min(self.width - 1, output_x * self.width // output_width) + for output_x in range(output_width) + ) + ) + ) + body = b"".join(rows) + stride = output_width * bytes_per_pixel + scanlines = b"".join( + b"\x00" + body[offset : offset + stride] for offset in range(0, len(body), stride) + ) + + def chunk(name: bytes, payload: bytes) -> bytes: + checksum = zlib.crc32(name + payload) & 0xFFFFFFFF + return struct.pack(">I", len(payload)) + name + payload + struct.pack(">I", checksum) + + header = struct.pack( + ">IIBBBBB", + output_width, + output_height, + 16 if self.bit_depth > 8 else 8, + 0, + 0, + 0, + 0, + ) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(scanlines)) + + chunk(b"IEND", b"") + ) + + def save_png(self, path: str, maximum_size: Optional[int] = None) -> None: + """Save the raw frame as a browser-compatible grayscale PNG image.""" + with open(path, "wb") as output: + output.write(self.to_png_bytes(maximum_size=maximum_size)) + + +class CeligoCamera(Protocol): + """Camera interface consumed by :class:`pylabrobot.revvity.celigo.Celigo`.""" + + exposure_ms: float + gain: float + width: int + height: int + + @property + def is_open(self) -> bool: ... + + async def setup(self) -> None: ... + + async def stop(self) -> None: ... + + async def set_exposure(self, exposure_ms: float) -> float: ... + + async def set_gain(self, gain: float) -> float: ... + + async def set_frame_format( + self, + width: int, + height: int, + x_offset: Optional[int] = None, + y_offset: Optional[int] = None, + ) -> Tuple[int, int]: ... + + async def capture(self, flush_frames: int = 2) -> CameraFrame: ... + + +class LumeneraCamera: + """Lumenera camera connected to a Celigo, accessed through ``liblucamapi``.""" + + def __init__( + self, + camera_index: int = 1, + sdk_library: Optional[str] = None, + library: Optional[Any] = None, + sdk_call_timeout: float = 30.0, + ): + if sdk_call_timeout <= 0: + raise ValueError("sdk_call_timeout must be positive") + self.camera_index = camera_index + self.sdk_library = sdk_library or os.environ.get("LUCAM_SDK_LIBRARY") + self._lib = library + self.sdk_call_timeout = sdk_call_timeout + self._executor: Optional[_SerializedDaemonExecutor] = _SerializedDaemonExecutor() + self._handle: Optional[int] = None + self._streaming = False + self._pending_cleanup: Optional[concurrent.futures.Future[Any]] = None + self.width = 0 + self.height = 0 + self.x_offset = 0 + self.y_offset = 0 + self.bit_depth = 8 + self.frame_rate = 0.0 + self.exposure_ms = 0.0 + self.gain = 0.0 + + @property + def is_open(self) -> bool: + return self._handle is not None and self._pending_cleanup is None + + @functools.cached_property + def _lock(self) -> asyncio.Lock: + return asyncio.Lock() + + def _queue_deferred_close( + self, executor: _SerializedDaemonExecutor + ) -> concurrent.futures.Future[Any]: + cleanup = executor.submit(self._stop_sync) + self._pending_cleanup = cleanup + + def shutdown_worker(_future: concurrent.futures.Future[Any]) -> None: + executor.shutdown() + if self._executor is executor: + self._executor = None + + cleanup.add_done_callback(shutdown_worker) + return cleanup + + async def _run_blocking(self, function: Callable[..., _T], *args: Any) -> _T: + """Run one SDK call without using asyncio's process-wide default executor. + + Some Python runtimes do not reliably shut down their default executor when the + event loop runs in debug mode. A small, owned executor avoids that lifecycle + coupling and also ensures that no Lumenera worker thread outlives the call. + """ + pending_cleanup = self._pending_cleanup + if pending_cleanup is not None: + if not pending_cleanup.done(): + raise CameraError( + "A timed-out Lumenera call is still running; the camera is poisoned until " + "its deferred close completes" + ) + # Surface a deferred-close exception before accepting another SDK call. + pending_cleanup.result() + self._pending_cleanup = None + call = functools.partial(function, *args) + if self._executor is None: + self._executor = _SerializedDaemonExecutor() + executor = self._executor + worker = executor.submit(call) + deadline = asyncio.get_running_loop().time() + self.sdk_call_timeout + try: + while not worker.done(): + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise asyncio.TimeoutError + # Polling avoids relying on a cross-thread event-loop callback after a native + # call completes, which is unreliable on some embedded/diagnostic event loops. + await asyncio.sleep(min(0.005, remaining)) + return worker.result() + except asyncio.CancelledError: + self._queue_deferred_close(executor) + raise + except asyncio.TimeoutError as exc: + # A running native call cannot be cancelled. Queue close on the same one-worker + # executor, so it runs after (never concurrently with) the abandoned call. + self._queue_deferred_close(executor) + raise CameraError( + f"Lumenera SDK call exceeded {self.sdk_call_timeout:g} second timeout; " + "camera close is queued behind the native call" + ) from exc + + def _load_library(self) -> Any: + if self._lib is None: + candidates = [self.sdk_library] if self.sdk_library else [] + discovered = ctypes.util.find_library("lucamapi") + if discovered: + candidates.append(discovered) + candidates.extend(["lucamapi.dll", "liblucamapi.dylib", "liblucamapi.so"]) + errors = [] + for path in dict.fromkeys(candidate for candidate in candidates if candidate): + try: + self._lib = ctypes.CDLL(path) + break + except OSError as exc: + errors.append(f"{path}: {exc}") + if self._lib is None: + raise CameraError( + "Could not load the Lumenera SDK library; pass sdk_library= or set " + f"LUCAM_SDK_LIBRARY. Tried: {'; '.join(errors)}" + ) + lib = self._lib + + # Bind signatures to the same attribute-resolved function objects used below. + # ``ctypes.CDLL.__getitem__`` creates a distinct object, so configuring + # ``lib[name]`` would leave ``lib.LucamCameraOpen`` with its unsafe default + # 32-bit return type and truncate 64-bit camera handles. + def set_signature(function: Any, restype: Any, argtypes: list[Any]) -> None: + try: + function.restype = restype + function.argtypes = argtypes + except AttributeError: + return + + try: + set_signature(lib.LucamCameraOpen, ctypes.c_void_p, [ctypes.c_uint32]) + set_signature(lib.LucamCameraClose, ctypes.c_int, [ctypes.c_void_p]) + set_signature( + lib.LucamStreamVideoControl, + ctypes.c_int, + [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p], + ) + set_signature( + lib.LucamGetFormat, + ctypes.c_int, + [ctypes.c_void_p, ctypes.POINTER(_LucamFrameFormat), ctypes.POINTER(ctypes.c_float)], + ) + set_signature( + lib.LucamSetFormat, + ctypes.c_int, + [ctypes.c_void_p, ctypes.POINTER(_LucamFrameFormat), ctypes.c_float], + ) + set_signature( + lib.LucamTakeVideo, + ctypes.c_int, + [ctypes.c_void_p, ctypes.c_int32, ctypes.c_void_p], + ) + set_signature( + lib.LucamGetProperty, + ctypes.c_int, + [ + ctypes.c_void_p, + ctypes.c_uint32, + ctypes.POINTER(ctypes.c_float), + ctypes.POINTER(ctypes.c_int32), + ], + ) + set_signature( + lib.LucamSetProperty, + ctypes.c_int, + [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_float, ctypes.c_int32], + ) + set_signature( + lib.LucamGetLastErrorForCamera, + ctypes.c_uint32, + [ctypes.c_void_p], + ) + except AttributeError as exc: + raise CameraError(f"Lumenera SDK is missing a required export: {exc}") from exc + return lib + + def _request_last_sdk_error_code(self) -> int: + if self._lib is None or self._handle is None: + return 0 + return int(self._lib.LucamGetLastErrorForCamera(self._handle)) + + def _require_library(self) -> Any: + if self._lib is None: + raise CameraError("Lumenera SDK is not loaded; call setup() first") + return self._lib + + def _raise_if_sdk_call_failed(self, sdk_result: Any, operation: str) -> None: + if not sdk_result: + raise CameraError( + f"{operation} failed (Lumenera error {self._request_last_sdk_error_code()})" + ) + + def _setup_sync(self) -> None: + lib = self._load_library() + handle = lib.LucamCameraOpen(self.camera_index) + if not handle: + raise CameraError("LucamCameraOpen failed") + self._handle = handle + try: + self._raise_if_sdk_call_failed( + lib.LucamStreamVideoControl(handle, _START_STREAMING, None), "start camera stream" + ) + self._streaming = True + frame_format = _LucamFrameFormat() + frame_rate = ctypes.c_float() + self._raise_if_sdk_call_failed( + lib.LucamGetFormat(handle, ctypes.byref(frame_format), ctypes.byref(frame_rate)), + "read camera format", + ) + self._update_frame_format_state(frame_format, float(frame_rate.value)) + self.exposure_ms = self._request_property_value_sync(LUCAM_PROP_EXPOSURE) + self.gain = self._request_property_value_sync(LUCAM_PROP_GAIN) + except Exception: + self._stop_sync() + raise + + async def setup(self) -> None: + """Open the first camera and start its video stream.""" + async with self._lock: + if not self.is_open: + try: + await self._run_blocking(self._setup_sync) + except BaseException: + if self._pending_cleanup is None and self._executor is not None: + self._executor.shutdown() + self._executor = None + raise + + def _update_frame_format_state(self, frame_format: _LucamFrameFormat, frame_rate: float) -> None: + if frame_format.pixel_format not in (LUCAM_PF_8, LUCAM_PF_16): + raise CameraError( + f"Unsupported Lumenera pixel format {frame_format.pixel_format}; " + "only monochrome 8-bit and 16-bit formats are supported" + ) + subsample_x = max(1, int(frame_format.subsample_x)) + subsample_y = max(1, int(frame_format.subsample_y)) + self.width = int(frame_format.width // subsample_x) + self.height = int(frame_format.height // subsample_y) + self.x_offset = int(frame_format.x_offset) + self.y_offset = int(frame_format.y_offset) + if self.width <= 0 or self.height <= 0: + raise CameraError(f"Lumenera returned invalid frame dimensions {self.width}x{self.height}") + self.bit_depth = 16 if frame_format.pixel_format == LUCAM_PF_16 else 8 + self.frame_rate = frame_rate + + def _set_frame_format_sync( + self, + width: int, + height: int, + x_offset: Optional[int], + y_offset: Optional[int], + ) -> Tuple[int, int]: + handle = self._require_handle() + lib = self._require_library() + current = _LucamFrameFormat() + frame_rate = ctypes.c_float() + self._raise_if_sdk_call_failed( + lib.LucamGetFormat(handle, ctypes.byref(current), ctypes.byref(frame_rate)), + "read camera format", + ) + subsample_x = max(1, int(current.subsample_x)) + subsample_y = max(1, int(current.subsample_y)) + raw_width = width * subsample_x + raw_height = height * subsample_y + if raw_width > current.width or raw_height > current.height: + raise CameraError( + f"Requested camera format {width}x{height} exceeds current sensor window " + f"{current.width // subsample_x}x{current.height // subsample_y}" + ) + target_x = ( + int(current.x_offset + (current.width - raw_width) // 2) if x_offset is None else x_offset + ) + target_y = ( + int(current.y_offset + (current.height - raw_height) // 2) if y_offset is None else y_offset + ) + if target_x < 0 or target_y < 0: + raise ValueError("camera offsets must be non-negative") + target = _LucamFrameFormat( + x_offset=target_x, + y_offset=target_y, + width=raw_width, + height=raw_height, + pixel_format=current.pixel_format, + subsample_x=current.subsample_x, + flags_x=current.flags_x, + subsample_y=current.subsample_y, + flags_y=current.flags_y, + ) + was_streaming = self._streaming + if was_streaming: + self._raise_if_sdk_call_failed( + lib.LucamStreamVideoControl(handle, _STOP_STREAMING, None), + "stop camera stream for format change", + ) + self._streaming = False + try: + self._raise_if_sdk_call_failed( + lib.LucamSetFormat(handle, ctypes.byref(target), ctypes.c_float(frame_rate.value)), + "set camera format", + ) + actual = _LucamFrameFormat() + actual_rate = ctypes.c_float() + self._raise_if_sdk_call_failed( + lib.LucamGetFormat(handle, ctypes.byref(actual), ctypes.byref(actual_rate)), + "read back camera format", + ) + self._update_frame_format_state(actual, float(actual_rate.value)) + if (self.width, self.height) != (width, height): + raise CameraError( + f"Lumenera accepted {self.width}x{self.height}, not requested {width}x{height}" + ) + finally: + if was_streaming: + self._raise_if_sdk_call_failed( + lib.LucamStreamVideoControl(handle, _START_STREAMING, None), + "restart camera stream after format change", + ) + self._streaming = True + return self.width, self.height + + async def set_frame_format( + self, + width: int, + height: int, + x_offset: Optional[int] = None, + y_offset: Optional[int] = None, + ) -> Tuple[int, int]: + """Set and verify a camera ROI, centered when offsets are omitted.""" + if width <= 0 or height <= 0: + raise ValueError("camera width and height must be positive") + async with self._lock: + return await self._run_blocking( + self._set_frame_format_sync, width, height, x_offset, y_offset + ) + + def _stop_sync(self) -> None: + if self._lib is None or self._handle is None: + return + handle = self._handle + if self._streaming: + self._lib.LucamStreamVideoControl(handle, _STOP_STREAMING, None) + self._lib.LucamCameraClose(handle) + self._streaming = False + self._handle = None + + async def stop(self) -> None: + """Stop streaming and close the camera.""" + async with self._lock: + try: + await self._run_blocking(self._stop_sync) + finally: + if self._pending_cleanup is None and self._executor is not None: + self._executor.shutdown() + self._executor = None + + def _require_handle(self) -> int: + if self._handle is None: + raise CameraError("Camera is not open; call setup() first") + return self._handle + + def _request_property_value_sync(self, property_id: int) -> float: + handle = self._require_handle() + lib = self._require_library() + value = ctypes.c_float() + flags = ctypes.c_int32() + self._raise_if_sdk_call_failed( + lib.LucamGetProperty(handle, property_id, ctypes.byref(value), ctypes.byref(flags)), + f"read camera property {property_id}", + ) + return float(value.value) + + async def request_property_value(self, property_id: int) -> float: + async with self._lock: + return await self._run_blocking(self._request_property_value_sync, property_id) + + def _set_property_sync(self, property_id: int, property_value: float) -> float: + handle = self._require_handle() + lib = self._require_library() + self._raise_if_sdk_call_failed( + lib.LucamSetProperty(handle, property_id, ctypes.c_float(property_value), 0), + f"set camera property {property_id}", + ) + return self._request_property_value_sync(property_id) + + async def set_exposure(self, exposure_ms: float) -> float: + if exposure_ms <= 0: + raise ValueError("exposure_ms must be positive") + async with self._lock: + self.exposure_ms = await self._run_blocking( + self._set_property_sync, LUCAM_PROP_EXPOSURE, exposure_ms + ) + return self.exposure_ms + + async def set_gain(self, gain: float) -> float: + if gain < 0: + raise ValueError("gain must be non-negative") + async with self._lock: + self.gain = await self._run_blocking(self._set_property_sync, LUCAM_PROP_GAIN, gain) + return self.gain + + def _capture_sync(self, flush_frames: int) -> CameraFrame: + handle = self._require_handle() + lib = self._require_library() + bytes_per_pixel = 2 if self.bit_depth > 8 else 1 + byte_count = self.width * self.height * bytes_per_pixel + if byte_count <= 0: + raise CameraError(f"Invalid capture geometry {self.width}x{self.height}") + buffer = ctypes.create_string_buffer(byte_count) + for frame_index in range(flush_frames + 1): + self._raise_if_sdk_call_failed(lib.LucamTakeVideo(handle, 1, buffer), "capture camera frame") + if frame_index < flush_frames: + time.sleep(max(0.001, self.exposure_ms / 1000.0)) + return CameraFrame( + data=buffer.raw[:byte_count], + width=self.width, + height=self.height, + bit_depth=self.bit_depth, + exposure_ms=self.exposure_ms, + gain=self.gain, + captured_at=time.time(), + ) + + async def capture(self, flush_frames: int = 2) -> CameraFrame: + """Capture a frame, discarding ``flush_frames`` stale streaming frames first.""" + if flush_frames < 0: + raise ValueError("flush_frames must be non-negative") + async with self._lock: + return await self._run_blocking(self._capture_sync, flush_frames) diff --git a/pylabrobot/revvity/celigo/celigo.py b/pylabrobot/revvity/celigo/celigo.py new file mode 100644 index 00000000000..f5107cdeb3c --- /dev/null +++ b/pylabrobot/revvity/celigo/celigo.py @@ -0,0 +1,2039 @@ +"""Revvity Celigo image cytometer. + +Drives the Celigo's FTDI-based USB-IO controller board over a serial link: the XY +stage, Z/focus, filter wheel (AllMotion EZStepper motors with encoder feedback), the +brightfield illumination DAC, and the galvo mirrors. + +Wire protocol (all multi-byte fields big-endian). Every exchange is a request packet +followed by a response packet: + + Request (11-byte header + payload): + [opcode:1][sequence:i32][total_length:i32][fletcher16:2] + payload + The fletcher16 covers the first 9 header bytes. + + Response (12-byte header + payload): + [ack:1][opcode_echo:1][sequence_echo:i32][payload_length:i32][fletcher16:2] + payload + The fletcher16 covers the first 10 header bytes; ack 0 == OK. + +The stage/Z/filter motors are AllMotion EZStepper drivers. Their ASCII commands +("/R\\r") are tunneled through the board's MOTOR_CMD_QUERY_WLEN opcode, +wrapped in an OEM frame (STX + addr + '1' + tokens + ETX + xor-checksum). + +Connection: FTDI via libftdi (pylibftdi) at 230400 baud, 8 data bits, no parity, 1 stop +bit. libftdi claims the FTDI device directly, so the kernel ``ftdi_sio`` driver must not +hold it (any ``/dev/ttyUSB*`` for this board goes away while the driver is connected). +""" + +import asyncio +import contextlib +import logging +import math +import struct +import time +from dataclasses import dataclass +from datetime import timedelta +from functools import cached_property, partial +from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Sequence, Tuple, cast + +from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources.plate import Plate +from pylabrobot.revvity.celigo.camera import CameraFrame, CeligoCamera, LumeneraCamera +from pylabrobot.revvity.celigo.config import ( + AxisConfig, + CeligoConfig, + DigitalIOConfig, + FilterWheelConfig, + IlluminationChannelConfig, + LightingIOConfig, +) +from pylabrobot.revvity.celigo.coordinates import CoordinateSystems +from pylabrobot.revvity.celigo.errors import CeligoError +from pylabrobot.revvity.celigo.galvo import Galvo +from pylabrobot.revvity.celigo.laser import Laser +from pylabrobot.revvity.celigo.motion import ( + Axis, + FilterWheel, + LinearAxis, + MagnificationChanger, + MotorController, +) +from pylabrobot.revvity.celigo.navigation import well_to_sample_mm, well_to_stage_mm +from pylabrobot.revvity.celigo.protocol import ( + complete_cleanup, + validate_payload_length, +) +from pylabrobot.revvity.celigo.scan import ( + AutofocusMethod, + BlockShape, + FrameResult, + PlannedFrame, + ScanBlock, + ScanEstimateModel, + ScanPlan, + ScanPosition, + ScanResult, + ScanSpec, + build_scan_plan, +) + +logger = logging.getLogger(__name__) + +DEFAULT_BAUDRATE = 230400 + +# Board command opcodes (byte 0 of every packet). +_CMD_ABORT = 3 +_CMD_SEND_MOTOR_CONFIG = 9 +_CMD_READ_DIG_PORT = 15 +_CMD_SET_DIG_PORT_BITS = 16 +_CMD_CLEAR_DIG_PORT_BITS = 17 +_CMD_WRITE_DA_CHANNEL = 18 +_CMD_READ_AD_CHANNEL = 20 +_CMD_SEND_CONFIG = 22 +_CMD_CONTROLLER_STATUS = 23 +_CMD_RESET_CONTROLLER = 25 +_CMD_GET_DIG_OUT_VALUE = 34 +_CMD_GET_ANALOG_OUT_VALUE = 35 +_CMD_SIGNAL_DIAGNOSTICS = 43 +_CMD_SEND_BARCODE_MSG = 45 +_CMD_READ_BARCODE_MSG = 46 + +# SIGNAL_DIAGNOSTICS sub-commands (camera trigger / status line). +_DIAG_SET_TRIGGER = 1 +_DIAG_CLEAR_TRIGGER = 2 +_DIAG_PULSE_TRIGGER = 3 +_DIAG_READ_BUSY = 4 +_DIAG_READ_INTEGRATION = 5 +_DIAG_READ_ENCODER = 6 + +_MAX_RESPONSE_PAYLOAD_BYTES = 65535 + +# Response ack status byte. +_ACK_OK = 0 +_ACK_MESSAGES = { + 1: "Invalid command checksum", + 2: "Invalid command", + 3: "Command read failed", + 4: "Command rejected", + 5: "Invalid parameter", +} +# Ack codes worth retrying after flushing the input buffer. +_ACK_RETRYABLE = frozenset({1, 3}) + +_TX_HEADER_SIZE = 11 +_RX_HEADER_SIZE = 12 + +# Controller status flags returned by CONTROLLER_STATUS. +_STATUS_BUSY = 1 +_STATUS_ERROR = 2 +_STATUS_INTERLOCK_OPEN = 4 +_STATUS_CONTROLLER_FAIL = 8 + +LinearAxisName = Literal["x", "y", "z"] +OpticalComponentName = Literal[ + "beam_expander", + "camera_filter", + "dichroic_filter", + "door", + "excitation_filter", + "excitation_nd_filter", + "laser_attenuator", + "laser_nd_filter", + "magnification", +] + +_LINEAR_AXIS_NAMES: Tuple[LinearAxisName, ...] = ("x", "y", "z") + +# 12-bit per-channel analog DAC full scale. +_ANALOG_DAC_FULL_SCALE = 4095.0 + +IlluminationChannelName = str + + +@dataclass(frozen=True) +class ControllerInfo: + """Board identity from SEND_CONFIG: device index, firmware version, UART buffer size.""" + + device_index: int + firmware_version: Tuple[int, int, int] # (major, minor, build) + uart_buffer_length: int + + +@dataclass(frozen=True) +class DetectedMotorAddress: + """One EZStepper address reported by a controller UART.""" + + uart_index: int + motor_index: int + + +@dataclass(frozen=True) +class ControllerStatus: + """Decoded controller status returned by :meth:`Celigo.request_controller_status`.""" + + raw_flags: int + extended_status: int + + @property + def busy(self) -> bool: + return bool(self.raw_flags & _STATUS_BUSY) + + @property + def error(self) -> bool: + return bool(self.raw_flags & _STATUS_ERROR) + + @property + def interlock_open(self) -> bool: + return bool(self.raw_flags & _STATUS_INTERLOCK_OPEN) + + @property + def controller_failed(self) -> bool: + return bool(self.raw_flags & _STATUS_CONTROLLER_FAIL) + + @property + def has_controller_fault(self) -> bool: + """Whether the controller reports an error or internal failure.""" + return self.error or self.controller_failed + + @property + def has_laser_safety_fault(self) -> bool: + """Whether controller health or the generic interlock makes laser use unsafe.""" + return self.has_controller_fault or self.interlock_open + + +@dataclass(frozen=True) +class FocusResult: + """Best Z position, scored Z samples, and verified final autofocus frame.""" + + z_ticks: int + z_mm: float + score: float + scored_z_samples: Tuple[Tuple[int, float], ...] + frame: CameraFrame + + +@dataclass(frozen=True) +class AcquisitionResult: + """A captured frame plus motion/optical metadata used for the acquisition.""" + + label: str + channel: str + x_mm: float + y_mm: float + z_mm: float + frame: CameraFrame + focus: Optional[FocusResult] + galvo_hardware_voltages: Tuple[float, float] + + +@dataclass(frozen=True) +class SelfTestReport: + """Read-only or active controller self-test results.""" + + passed: bool + checks: Dict[str, Any] + failures: Tuple[str, ...] + + +@dataclass(frozen=True) +class _DrawerLoadTargets: + """Stage positions used to return a plate beneath the optics.""" + + x_park_mm: float + y_clearance_mm: float + y_park_mm: float + + +def _fletcher16(data: bytes, byte_count: int) -> Tuple[int, int]: + """Fletcher-16 checksum: seeds 0xFF/0xFF, folded in 21-byte blocks.""" + s1 = 0xFF + s2 = 0xFF + i = 0 + remaining = byte_count + while remaining > 0: + block = min(21, remaining) + remaining -= block + while block > 0: + s1 = (s1 + data[i]) & 0xFFFF + i += 1 + s2 = (s2 + s1) & 0xFFFF + block -= 1 + s1 = (s1 & 0xFF) + (s1 >> 8) + s2 = (s2 & 0xFF) + (s2 >> 8) + s1 = (s1 & 0xFF) + (s1 >> 8) + s2 = (s2 & 0xFF) + (s2 >> 8) + return s1 & 0xFF, s2 & 0xFF + + +def _build_command_packet(opcode: int, sequence: int, payload: bytes = b"") -> bytes: + """Serialize a request packet (11-byte header + payload).""" + header = bytearray(_TX_HEADER_SIZE) + header[0] = opcode + struct.pack_into(">i", header, 1, sequence) + struct.pack_into(">i", header, 5, _TX_HEADER_SIZE + len(payload)) + check_a, check_b = _fletcher16(header, 9) + header[9] = check_a + header[10] = check_b + return bytes(header) + payload + + +def _volts_to_analog_dac(volts: float, min_voltage: float, max_voltage: float) -> int: + """Map an in-range voltage to a 12-bit per-channel analog DAC count.""" + if not all(math.isfinite(value) for value in (volts, min_voltage, max_voltage)): + raise ValueError("analog voltage limits and target must be finite") + if max_voltage <= min_voltage: + raise ValueError("analog max_voltage must be greater than min_voltage") + if not min_voltage <= volts <= max_voltage: + raise ValueError(f"analog voltage {volts} is outside {min_voltage}..{max_voltage}") + scaled = (volts - min_voltage) / (max_voltage - min_voltage) * _ANALOG_DAC_FULL_SCALE + return int(scaled) + + +def _analog_dac_to_volts( + dac_count: int, + min_voltage: float, + max_voltage: float, +) -> float: + """Inverse of :func:`_volts_to_analog_dac`.""" + if not 0 <= dac_count <= int(_ANALOG_DAC_FULL_SCALE): + raise ValueError("DAC count must be in 0..4095") + if not all(math.isfinite(value) for value in (min_voltage, max_voltage)): + raise ValueError("analog voltage limits must be finite") + if max_voltage <= min_voltage: + raise ValueError("analog max_voltage must be greater than min_voltage") + return dac_count / _ANALOG_DAC_FULL_SCALE * (max_voltage - min_voltage) + min_voltage + + +class Celigo: + """Celigo image cytometer motion/illumination controller. + + Talks to the FTDI-based USB-IO board over serial. Exposes stage/Z motion in + millimeters, drawer open/close (stage eject/load), imaging-channel selection + (brightfield + fluorescence), galvo steering, and the board's digital/analog IO and + barcode reader. Load an instrument's configuration with + :meth:`CeligoConfig.from_install` and pass the result as ``config``. + """ + + def __init__( + self, + config: CeligoConfig, + device_id: Optional[str] = None, + usb_address: Optional[str] = None, + vid: int = 0x0403, + pid: int = 0x6001, + baudrate: int = DEFAULT_BAUDRATE, + latency_ms: int = 2, + reply_timeout: float = 2.0, + move_timeout: float = 30.0, + lucam_sdk: Optional[str] = None, + allow_laser: bool = False, + fluorescence_warmup_seconds: float = 300.0, + fluorescence_power_change_interval: float = 10.0, + ): + if not math.isfinite(reply_timeout) or reply_timeout <= 0: + raise ValueError("reply_timeout must be a finite, positive number of seconds") + if not math.isfinite(move_timeout) or move_timeout <= 0: + raise ValueError("move_timeout must be a finite, positive number of seconds") + if not math.isfinite(fluorescence_warmup_seconds) or fluorescence_warmup_seconds < 0: + raise ValueError("fluorescence_warmup_seconds must be finite and non-negative") + if ( + not math.isfinite(fluorescence_power_change_interval) + or fluorescence_power_change_interval < 0 + ): + raise ValueError("fluorescence_power_change_interval must be finite and non-negative") + self.baudrate = baudrate + self.latency_ms = latency_ms + self.reply_timeout = reply_timeout + self.move_timeout = move_timeout + self.config = config + self._plate: Optional[Plate] = None + self.camera: CeligoCamera = LumeneraCamera(sdk_library=lucam_sdk) + self.galvo = Galvo(self) + self.laser = Laser(self, enabled=allow_laser) + self.fluorescence_warmup_seconds = fluorescence_warmup_seconds + self.fluorescence_power_change_interval = fluorescence_power_change_interval + self.current_channel: Optional[str] = None + self._connected = False + has_lamp_power = bool( + config.hardware.io is not None + and any( + output.io_name == "ExcitationLampPower" + and output.enabled + and output.io_type.strip().lower() == "out" + for output in config.hardware.io.digital_ios + ) + ) + self._fluorescence_on_since: Optional[float] = 0.0 if not has_lamp_power else None + self._last_fluorescence_power_change: Optional[float] = None + self.controller_info: Optional[ControllerInfo] = None + self._command_sequence = 1 + self.io = FTDI( + human_readable_device_name="Celigo", + device_id=device_id, + usb_address=usb_address, + vid=vid, + pid=pid, + ) + self.motor_controller = MotorController(self) + self._linear_axes = self._build_linear_axes() + self._optical_axes = self._build_optical_axes() + self._validate_unique_motor_addresses() + + @property + def controller_firmware_version(self) -> Optional[Tuple[int, int, int]]: + """The identified controller-board firmware version, if setup has reached identification.""" + return None if self.controller_info is None else self.controller_info.firmware_version + + @cached_property + def _command_lock(self) -> asyncio.Lock: + return asyncio.Lock() + + def _build_linear_axes(self) -> Dict[LinearAxisName, LinearAxis]: + hardware = self.config.hardware + configured = { + "x": hardware.x_axis, + "y": hardware.y_axis, + "z": hardware.z_axis, + } + return { + cast(LinearAxisName, name): LinearAxis( + self.motor_controller, + cast(LinearAxisName, name), + axis_config, + ) + for name, axis_config in configured.items() + if axis_config is not None and axis_config.enabled and axis_config.axis_index > 0 + } + + def _build_optical_axes(self) -> Dict[OpticalComponentName, Axis]: + hardware = self.config.hardware + configured: Dict[OpticalComponentName, Optional[AxisConfig]] = { + "beam_expander": hardware.beam_expander, + "camera_filter": hardware.camera_filter_wheel, + "dichroic_filter": hardware.dichroic_filter_wheel, + "door": hardware.door, + "excitation_filter": hardware.excitation_filter_wheel, + "excitation_nd_filter": hardware.excitation_nd_filter_wheel, + "laser_attenuator": hardware.laser_attenuator, + "laser_nd_filter": hardware.laser_nd_filter_wheel, + "magnification": hardware.magnification_changer, + } + axes: Dict[OpticalComponentName, Axis] = {} + for name, axis_config in configured.items(): + if axis_config is None or not axis_config.enabled or axis_config.axis_index <= 0: + continue + if isinstance(axis_config, FilterWheelConfig): + axes[name] = ( + MagnificationChanger(self.motor_controller, axis_config, self.config) + if name == "magnification" + else FilterWheel(self.motor_controller, name, axis_config) + ) + else: + axes[name] = Axis(self.motor_controller, name, axis_config) + return axes + + def _validate_unique_motor_addresses(self) -> None: + by_index: Dict[int, Axis] = {} + for axis in (*self._linear_axes.values(), *self._optical_axes.values()): + existing = by_index.get(axis.axis_index) + if existing is not None and existing is not axis: + raise CeligoError( + f"Enabled mechanisms {existing.config.motion_name!r} and " + f"{axis.config.motion_name!r} share motor address {axis.axis_index}" + ) + by_index[axis.axis_index] = axis + + def _require_linear_axis(self, name: LinearAxisName) -> LinearAxis: + try: + return self._linear_axes[name] + except KeyError as exc: + raise CeligoError(f"axis {name!r} is not configured") from exc + + def _require_optical_axis(self, component: OpticalComponentName) -> Axis: + try: + return self._optical_axes[component] + except KeyError as exc: + raise CeligoError( + f"Optical component {component!r} is not configured on this instrument" + ) from exc + + def _require_filter_wheel(self, component: OpticalComponentName) -> FilterWheel: + axis = self._require_optical_axis(component) + if not isinstance(axis, FilterWheel): + raise CeligoError(f"Optical component {component!r} is not a filter wheel") + return axis + + @property + def x_axis(self) -> LinearAxis: + return self._require_linear_axis("x") + + @property + def y_axis(self) -> LinearAxis: + return self._require_linear_axis("y") + + @property + def z_axis(self) -> LinearAxis: + return self._require_linear_axis("z") + + @property + def dichroic_filter(self) -> FilterWheel: + return self._require_filter_wheel("dichroic_filter") + + @property + def camera_filter(self) -> FilterWheel: + return self._require_filter_wheel("camera_filter") + + @property + def excitation_filter(self) -> FilterWheel: + return self._require_filter_wheel("excitation_filter") + + @property + def excitation_nd_filter(self) -> FilterWheel: + return self._require_filter_wheel("excitation_nd_filter") + + @property + def beam_expander(self) -> Axis: + return self._require_optical_axis("beam_expander") + + @property + def magnification_changer(self) -> MagnificationChanger: + axis = self._require_optical_axis("magnification") + if not isinstance(axis, MagnificationChanger): + raise CeligoError("The configured magnification mechanism is not a filter wheel") + return axis + + def _configured_motion_axes(self) -> List[Axis]: + axes = [*self._linear_axes.values(), *self._optical_axes.values()] + return sorted(axes, key=lambda axis: axis.axis_index) + + def _require_digital_io(self, io_name: str) -> DigitalIOConfig: + hardware = self.config.hardware + if hardware.io is None: + raise CeligoError("Celigo IO configuration is missing") + for io_config in hardware.io.digital_ios: + if io_config.io_name == io_name: + if not io_config.enabled: + raise CeligoError(f"Digital output {io_name!r} is disabled") + if io_config.io_type.strip().lower() != "out": + raise CeligoError(f"Digital IO {io_name!r} is not configured as an output") + return io_config + raise CeligoError(f"Celigo IO configuration has no {io_name!r} entry") + + def _find_digital_io(self, io_name: str) -> Optional[DigitalIOConfig]: + hardware = self.config.hardware + if hardware.io is None: + raise CeligoError("Celigo IO configuration is missing") + return next( + ( + item + for item in hardware.io.digital_ios + if item.io_name == io_name and item.enabled and item.io_type.strip().lower() == "out" + ), + None, + ) + + def _require_lighting_io(self, io_name: str) -> LightingIOConfig: + hardware = self.config.hardware + if hardware.io is None: + raise CeligoError("Celigo IO configuration is missing") + for io_config in hardware.io.lighting_ios: + if io_config.io_name == io_name: + if not io_config.enabled: + raise CeligoError(f"Lighting output {io_name!r} is disabled") + return io_config + raise CeligoError(f"Celigo IO configuration has no {io_name!r} entry") + + def _require_channel_config(self, channel: str) -> IlluminationChannelConfig: + try: + return self.config.channels[channel] + except KeyError as exc: + raise CeligoError( + f"Channel {channel!r} is not configured; available channels: " + f"{', '.join(sorted(self.config.channels)) or 'none'}" + ) from exc + + # -- lifecycle ------------------------------------------------------------- + + async def setup(self) -> None: + io_open = False + try: + await self.io.setup() + io_open = True + await self.io.set_baudrate(self.baudrate) + await self.io.set_line_property(8, 0, 0) # 8 data bits, 1 stop bit, no parity + await self.io.set_latency_timer(self.latency_ms) + await self.io.usb_purge_rx_buffer() + await self.io.usb_purge_tx_buffer() + for _ in range(2): + with contextlib.suppress(CeligoError): + await self.abort_controller_operation() + # The first command after opening can drop; read status a few times to warm up. + status: Optional[ControllerStatus] = None + last_status_error: Optional[CeligoError] = None + for _ in range(3): + try: + status = await self.request_controller_status() + break + except CeligoError as exc: + last_status_error = exc + await asyncio.sleep(0.1) + if status is None: + raise CeligoError("Celigo did not return a valid controller status") from last_status_error + + # Identity is required to choose the correct motor-tunnel framing safely. + self.controller_info = await self.request_controller_info() + await self._initialize_hardware() + await self.camera.setup() + await self._configure_camera_for_calibration() + await self.home_imaging_axes() + except BaseException: + if io_open: + with contextlib.suppress(Exception): + await self.abort_controller_operation() + with contextlib.suppress(Exception): + await self._initialize_safe_outputs() + with contextlib.suppress(Exception): + await self.camera.stop() + if io_open: + with contextlib.suppress(Exception): + await self.io.stop() + raise + self._connected = True + logger.info("[Celigo] connected (status=%s, %s)", status, self.controller_info) + + async def stop(self) -> None: + first_error: Optional[BaseException] = None + + async def attempt(operation) -> None: + nonlocal first_error + try: + await operation() + except BaseException as exc: + if first_error is None: + first_error = exc + + if self._connected: + await attempt(self.abort_controller_operation) + await attempt(self._initialize_safe_outputs) + await attempt(self.camera.stop) + await attempt(self.io.stop) + self._connected = False + if first_error is not None: + raise first_error + + # -- packet layer ---------------------------------------------------------- + + async def _read_exact_bytes(self, byte_count: int, reply_timeout: float) -> bytes: + chunks = [] + remaining = byte_count + deadline = time.monotonic() + reply_timeout + while remaining > 0: + chunk = await self.io.read(remaining) + if chunk: + chunks.append(chunk) + remaining -= len(chunk) + continue + if time.monotonic() >= deadline: + break + await asyncio.sleep(0.001) + received_bytes = b"".join(chunks) + if len(received_bytes) != byte_count: + raise CeligoError(f"Short read: expected {byte_count} bytes, got {len(received_bytes)}") + return received_bytes + + async def send_command( + self, + opcode: int, + payload: bytes = b"", + retries: int = 3, + reply_timeout: Optional[float] = None, + ) -> bytes: + """Send a command and return its response payload (b'' if there is none).""" + selected_reply_timeout = self.reply_timeout if reply_timeout is None else reply_timeout + if retries <= 0: + raise ValueError("retries must be positive") + if not math.isfinite(selected_reply_timeout) or selected_reply_timeout <= 0: + raise ValueError("reply_timeout must be a finite, positive number of seconds") + async with self._command_lock: + self._command_sequence += 1 + sequence = self._command_sequence + command_packet = _build_command_packet(opcode, sequence, payload) + attempt = 0 + while True: + attempt += 1 + try: + written = await self.io.write(command_packet) + if written != len(command_packet): + raise CeligoError(f"Short write: expected {len(command_packet)} bytes, wrote {written}") + return await self._read_controller_response( + opcode, + sequence, + selected_reply_timeout, + ) + except BaseException as exc: + # A cancelled executor-backed FTDI read can still consume bytes in its worker. + # Queue both purges behind it before allowing another command to use the link. + with contextlib.suppress(Exception): + await complete_cleanup(self._purge_controller_buffers()) + if isinstance(exc, CeligoError) and exc.ack in _ACK_RETRYABLE and attempt < retries: + continue + raise + + async def _purge_controller_buffers(self) -> None: + """Purge both directions, attempting the second purge even if the first fails.""" + first_error: Optional[BaseException] = None + for purge in (self.io.usb_purge_rx_buffer, self.io.usb_purge_tx_buffer): + try: + await purge() + except BaseException as exc: + if first_error is None: + first_error = exc + if first_error is not None: + raise first_error + + async def _read_controller_response( + self, + opcode: int, + sequence: int, + reply_timeout: float, + ) -> bytes: + header = await self._read_exact_bytes(_RX_HEADER_SIZE, reply_timeout) + ack = header[0] + echo_opcode = header[1] + echo_seq = struct.unpack_from(">i", header, 2)[0] + payload_length = struct.unpack_from(">i", header, 6)[0] + + if (header[10], header[11]) != _fletcher16(header, 10): + raise CeligoError(f"Response checksum failure for opcode {opcode}, sequence {sequence}") + + if ack != _ACK_OK: + raise CeligoError( + f"{_ACK_MESSAGES.get(ack, f'Unknown ack {ack}')} (opcode {opcode})", + ack=ack, + ) + + if echo_opcode != opcode: + raise CeligoError(f"Reply opcode mismatch: expected {opcode}, got {echo_opcode}") + if echo_seq != sequence: + raise CeligoError(f"Reply sequence mismatch: expected {sequence}, got {echo_seq}") + if not 0 <= payload_length <= _MAX_RESPONSE_PAYLOAD_BYTES: + raise CeligoError( + f"Invalid response payload length {payload_length}; maximum is " + f"{_MAX_RESPONSE_PAYLOAD_BYTES} bytes" + ) + + return await self._read_exact_bytes(payload_length, reply_timeout) if payload_length else b"" + + # -- status / encoders ----------------------------------------------------- + + async def request_controller_status(self) -> ControllerStatus: + """Request and decode the current controller status.""" + response = await self.send_command(_CMD_CONTROLLER_STATUS) + validate_payload_length(response, 8, "controller status") + flags, extended_status = struct.unpack_from(">II", response, 0) + return ControllerStatus(flags, extended_status) + + async def request_controller_info(self) -> ControllerInfo: + """Read board identity (SEND_CONFIG): device index, firmware version, UART buffer size.""" + response = await self.send_command(_CMD_SEND_CONFIG) + validate_payload_length(response, 10, "controller info") + device_index, encoded_firmware, uart_buffer_length = struct.unpack_from( + ">hii", + response, + 0, + ) + firmware_version = ( + (encoded_firmware >> 16) & 0xFF, + (encoded_firmware >> 8) & 0xFF, + encoded_firmware & 0xFF, + ) + return ControllerInfo( + device_index=device_index, + firmware_version=firmware_version, + uart_buffer_length=uart_buffer_length, + ) + + async def request_is_safety_interlock_open(self) -> bool: + """Whether the controller reports the safety interlock switch as open.""" + return (await self.request_controller_status()).interlock_open + + async def request_is_busy(self) -> bool: + """Whether the controller reports the BUSY flag.""" + return (await self.request_controller_status()).busy + + async def wait_for_controller_ready( + self, + timeout: float = 5.0, + poll_interval: float = 0.01, + ) -> bool: + """Poll status until the controller BUSY flag clears; return False on timeout.""" + if not math.isfinite(timeout) or timeout < 0: + raise ValueError("timeout must be a finite, non-negative number of seconds") + if not math.isfinite(poll_interval) or poll_interval <= 0: + raise ValueError("poll_interval must be a finite, positive number of seconds") + deadline = time.monotonic() + timeout + while await self.request_is_busy(): + if time.monotonic() >= deadline: + return False + await asyncio.sleep(poll_interval) + return True + + async def request_detected_motor_addresses(self) -> List[DetectedMotorAddress]: + """Return the EZStepper addresses reported by the controller's UARTs.""" + response = await self.send_command(_CMD_SEND_MOTOR_CONFIG) + validate_payload_length(response, 40, "motor configuration") + motors: List[DetectedMotorAddress] = [] + offset = 0 + for uart_index in range(8): + offset += 1 # per-UART status byte + for _ in range(4): + motor_index = response[offset] + offset += 1 + if motor_index != 127: + motors.append( + DetectedMotorAddress( + uart_index=uart_index, + motor_index=motor_index, + ) + ) + return motors + + async def _initialize_hardware(self) -> None: + """Run the non-homing portion of the captured Celigo power-on sequence. + + This aborts stale operations, discovers the board/motors, configures galvo settling + windows, replays every configured motor profile, and calibrates both galvos. It + changes controller configuration but does not intentionally move a motor. + """ + await self.abort_controller_operation() + await self.abort_controller_operation() + # The vendor reads identity three times during startup; setup() already performed one. + for _ in range(2): + self.controller_info = await self.request_controller_info() + connected_motor_indices = { + motor.motor_index for motor in await self.request_detected_motor_addresses() + } + missing_axes = [ + axis + for axis in self._configured_motion_axes() + if axis.axis_index not in connected_motor_indices + ] + if missing_axes: + missing_descriptions = ", ".join( + f"{axis.config.motion_name or axis.name} ({axis.axis_index})" for axis in missing_axes + ) + raise CeligoError(f"Configured motors were not detected: {missing_descriptions}") + await self._initialize_safe_outputs() + for motion_axis in self._configured_motion_axes(): + await motion_axis._initialize() + await self.galvo._initialize() + + async def _initialize_safe_outputs(self) -> None: + """Put every controller output in the vendor startup's inactive state.""" + hardware = self.config.hardware + lighting_outputs = ( + {output.channel: output for output in hardware.io.lighting_ios if output.enabled} + if hardware.io is not None + else {} + ) + for channel_index in range(4): + lighting_output = lighting_outputs.get(channel_index) + if lighting_output is None: + await self.set_analog_output_count(channel_index, 0) + else: + await self._set_lighting_output_intensity(lighting_output, 0.0) + digital_outputs = ( + { + output.bit_index: output + for output in hardware.io.digital_ios + if output.enabled and output.io_type.strip().lower() == "out" + } + if hardware.io is not None + else {} + ) + for bit_index in range(12): + digital_output = digital_outputs.get(bit_index) + await self.set_digital_output( + bit_index, + digital_output.invert if digital_output is not None else False, + ) + self.current_channel = None + lamp_power = None if hardware.io is None else self._find_digital_io("ExcitationLampPower") + # Without a controllable power line the source is always powered. + fluorescence_on = lamp_power is None + self._fluorescence_on_since = 0.0 if fluorescence_on else None + self._last_fluorescence_power_change = None + + async def abort_controller_operation(self) -> None: + """Abort the current controller command.""" + await self.send_command(_CMD_ABORT) + await asyncio.sleep(0.05) + + async def reset_controller(self) -> None: + """Reset the controller board.""" + await self.send_command(_CMD_RESET_CONTROLLER) + + # -- digital & analog IO --------------------------------------------------- + + async def request_digital_input_bitmask(self) -> int: + """Read the digital input port as a raw bitmask.""" + response = await self.send_command(_CMD_READ_DIG_PORT) + validate_payload_length(response, 2, "digital input") + return int(struct.unpack_from(">H", response, 0)[0]) + + async def request_digital_input(self, bit_index: int) -> bool: + """Read one digital input line.""" + if not 0 <= bit_index < 12: + raise ValueError("digital bit must be in 0..11") + return bool(await self.request_digital_input_bitmask() & (1 << bit_index)) + + async def request_digital_output_bitmask(self) -> int: + """Read back the digital output register as a raw bitmask.""" + response = await self.send_command(_CMD_GET_DIG_OUT_VALUE) + validate_payload_length(response, 2, "digital output") + return int(struct.unpack_from(">H", response, 0)[0]) + + async def request_digital_output(self, bit_index: int) -> bool: + """Read back one digital output line.""" + if not 0 <= bit_index < 12: + raise ValueError("digital bit must be in 0..11") + return bool(await self.request_digital_output_bitmask() & (1 << bit_index)) + + async def set_digital_output(self, bit_index: int, high: bool) -> None: + """Drive one raw digital output line high or low.""" + if not 0 <= bit_index < 12: + raise ValueError("digital bit must be in 0..11") + mask = 1 << bit_index + opcode = _CMD_SET_DIG_PORT_BITS if high else _CMD_CLEAR_DIG_PORT_BITS + await self.send_command(opcode, struct.pack(">H", mask)) + + async def set_analog_output_count(self, channel_index: int, dac_count: int) -> None: + """Write a raw 12-bit count to an analog output (DAC) channel.""" + if not 0 <= channel_index < 4: + raise ValueError("analog output channel must be in 0..3") + if not 0 <= dac_count <= 0x0FFF: + raise ValueError("DAC count must be in 0..4095") + await self.send_command( + _CMD_WRITE_DA_CHANNEL, + struct.pack(">HH", channel_index, dac_count), + ) + + async def request_analog_output_count(self, channel_index: int) -> int: + """Read back an analog output (DAC) channel's raw count.""" + if not 0 <= channel_index < 4: + raise ValueError("analog output channel must be in 0..3") + response = await self.send_command( + _CMD_GET_ANALOG_OUT_VALUE, + struct.pack(">H", channel_index), + ) + validate_payload_length(response, 4, "analog output") + echoed_channel_index, dac_count = struct.unpack_from(">HH", response, 0) + if echoed_channel_index != channel_index: + raise CeligoError( + f"Analog-output reply channel mismatch: requested {channel_index}, " + f"received {echoed_channel_index}" + ) + return int(dac_count) + + async def request_analog_input_count(self, channel_index: int) -> int: + """Read an analog input (ADC) channel's raw count (e.g. a sensor).""" + if not 0 <= channel_index < 4: + raise ValueError("analog input channel must be in 0..3") + response = await self.send_command( + _CMD_READ_AD_CHANNEL, + struct.pack(">H", channel_index), + ) + validate_payload_length(response, 2, "analog input") + return int(struct.unpack_from(">H", response, 0)[0]) + + async def set_analog_output_voltage( + self, + channel_index: int, + voltage: float, + min_voltage: float, + max_voltage: float, + ) -> None: + """Set an analog output channel to a voltage (per-channel min/max calibration).""" + await self.set_analog_output_count( + channel_index, + _volts_to_analog_dac(voltage, min_voltage, max_voltage), + ) + + async def request_analog_output_voltage( + self, + channel_index: int, + min_voltage: float, + max_voltage: float, + ) -> float: + """Read back an analog output channel as a voltage.""" + return _analog_dac_to_volts( + await self.request_analog_output_count(channel_index), + min_voltage, + max_voltage, + ) + + async def request_analog_input_voltage( + self, + channel_index: int, + min_voltage: float, + max_voltage: float, + ) -> float: + """Read an analog input channel as a voltage.""" + return _analog_dac_to_volts( + await self.request_analog_input_count(channel_index), + min_voltage, + max_voltage, + ) + + # -- barcode --------------------------------------------------------------- + + async def send_barcode_command(self, command: str) -> None: + """Send an ASCII command to the barcode reader. + + On this build the barcode UART is shared with the front-panel status display. + """ + await self.send_command(_CMD_SEND_BARCODE_MSG, command.encode("ascii") + b"\x00") + + async def request_barcode(self) -> str: + """Read the barcode reader's ASCII response.""" + response = await self.send_command(_CMD_READ_BARCODE_MSG) + validate_payload_length(response, 4, "barcode") + response_length = struct.unpack_from(">H", response, 2)[0] + validate_payload_length(response, 4 + response_length, "barcode") + return response[4 : 4 + response_length].decode( + "ascii", + errors="replace", + ) + + # -- motion ---------------------------------------------------------------- + + async def home_imaging_axes(self) -> None: + """Home Z for clearance, synchronize magnification, then home X, Y, and dichroic.""" + await self.turn_off_illumination() + await self.z_axis.home() + if "magnification" in self._optical_axes: + await self.magnification_changer.home() + await self.magnification_changer.move_to(self.config.magnification) + await self.x_axis.home() + await self.y_axis.home() + await self.dichroic_filter.home() + + # -- drawer (stage eject / load) ------------------------------------------- + + def set_plate(self, plate: Plate) -> None: + """Set the plate used by drawer loading, well navigation, and acquisition.""" + if not isinstance(plate, Plate): + raise TypeError(f"plate must be a Plate, got {type(plate).__name__}") + self._plate = plate + + def _require_plate(self) -> Plate: + if self._plate is None: + raise CeligoError("Set a plate with set_plate() before navigating to a well") + return self._plate + + def _drawer_load_targets_from_sample_mm( + self, + x_mm: float, + y_mm: float, + ) -> _DrawerLoadTargets: + if not all(math.isfinite(value) for value in (x_mm, y_mm)): + raise ValueError("sample X/Y coordinates must be finite") + coordinates = CoordinateSystems.from_config( + self.config.calibration, + self.config.hardware_defaults, + ) + x_park_mm, y_park_mm = coordinates.sample_mm_to_stage_mm(x_mm, y_mm) + for axis, target_mm in ( + (self.x_axis, x_park_mm), + (self.y_axis, y_park_mm), + ): + low_mm, high_mm = sorted((axis.config.min_position, axis.config.max_position)) + if not math.isfinite(target_mm) or not low_mm <= target_mm <= high_mm: + raise CeligoError( + f"sample target ({x_mm:g}, {y_mm:g}) mm maps to {axis.name.upper()} " + f"{target_mm:g} mm outside configured range {low_mm:g}..{high_mm:g} mm" + ) + return _DrawerLoadTargets( + x_park_mm=x_park_mm, + y_clearance_mm=self.y_axis.config.min_position, + y_park_mm=y_park_mm, + ) + + async def open_drawer(self) -> None: + """Drive the stage out to the eject station so the plate is accessible. + + Retracts Z, moves Y to its configured clearance coordinate, then drives X negative + and Y positive to their limit sensors using the lighter loading-pose currents. + Already-active target limits are not driven again. + """ + await self.turn_off_illumination() + self.current_channel = None + await self.z_axis.move_to(self.z_axis.config.min_position) + await self.y_axis.move_to(self.y_axis.config.min_position) + x_eject_distance_ticks = self.x_axis._limit_move_distance_ticks() + y_eject_distance_ticks = self.y_axis._limit_move_distance_ticks() + for axis, distance_ticks, request_is_limit_active in ( + ( + self.x_axis, + -x_eject_distance_ticks, + self.x_axis.request_is_negative_limit_active, + ), + ( + self.y_axis, + y_eject_distance_ticks, + self.y_axis.request_is_positive_limit_active, + ), + ): + for _ in range(3): + if await request_is_limit_active(): + break + await axis._move_relative_to_limit( + distance_ticks, + move_current_percent=axis.config.loading_current_percentage, + ) + else: + raise CeligoError(f"drawer {axis.name.upper()} limit was not reached") + + async def close_drawer(self, well: str) -> None: + """Move the stage under the optics using calibrated plate/well coordinates.""" + sample_x_mm, sample_y_mm = well_to_sample_mm(self._require_plate(), well) + await self.close_drawer_to_sample_mm(sample_x_mm, sample_y_mm) + + async def close_drawer_to_sample_mm(self, x_mm: float, y_mm: float) -> None: + """Safely close the drawer to a calibrated sample-relative X/Y coordinate. + + A PyLabRobot plate is not required. The sample coordinate is transformed to stage + millimeters and both targets are validated before illumination or motion changes. + Z retracts before the drawer moves through Y clearance to the final X/Y position. + """ + targets = self._drawer_load_targets_from_sample_mm(x_mm, y_mm) + await self.turn_off_illumination() + self.current_channel = None + await self.z_axis.move_to(self.z_axis.config.min_position) + await self.y_axis.move_to(targets.y_clearance_mm) + await self.x_axis.move_to(targets.x_park_mm) + await self.y_axis.move_to(targets.y_park_mm) + + def well_position_mm(self, well: str) -> Tuple[float, float]: + """Return the calibrated X/Y stage position for a named well.""" + coordinates = CoordinateSystems.from_config( + self.config.calibration, self.config.hardware_defaults + ) + return well_to_stage_mm(self._require_plate(), well, coordinates) + + async def move_to_well( + self, + well: str, + retract_z: bool = False, + safe_z_mm: Optional[float] = None, + ) -> Tuple[float, float]: + """Move the stage to a calibrated well center and return settled X/Y millimeters.""" + x_mm, y_mm = self.well_position_mm(well) + if retract_z: + if safe_z_mm is None: + safe_z_mm = self.z_axis.config.min_position + await self.z_axis.move_to(safe_z_mm) + settled_x = await self.x_axis.move_to(x_mm) + settled_y = await self.y_axis.move_to(y_mm) + return settled_x, settled_y + + # -- illumination / channels ----------------------------------------------- + + async def _set_named_digital_output(self, io_name: str, active: bool) -> None: + output = self._require_digital_io(io_name) + await self.set_digital_output(output.bit_index, active != output.invert) + + @staticmethod + def _lighting_output_analog_count( + output: LightingIOConfig, + intensity_percent: float, + ) -> int: + if not math.isfinite(intensity_percent) or not 0 <= intensity_percent <= 100: + raise ValueError("intensity_percent must be finite and within 0..100") + if not math.isfinite(output.delay) or output.delay < 0: + raise CeligoError(f"Lighting output {output.io_name!r} has an invalid configured delay") + voltage = ( + output.min_voltage + (output.max_voltage - output.min_voltage) * intensity_percent / 100.0 + ) + if output.invert: + voltage = output.max_voltage - voltage + output.min_voltage + return _volts_to_analog_dac(voltage, output.min_voltage, output.max_voltage) + + async def _set_lighting_output_intensity( + self, + output: LightingIOConfig, + intensity_percent: float, + ) -> None: + await self.set_analog_output_count( + output.channel, + self._lighting_output_analog_count(output, intensity_percent), + ) + if output.delay: + await asyncio.sleep(output.delay) + + async def _set_channel_intensity( + self, + channel_config: IlluminationChannelConfig, + intensity_percent: Optional[float] = None, + ) -> None: + output = self._require_lighting_io(channel_config.lighting_io_name) + await self._set_lighting_output_intensity( + output, + channel_config.intensity_percent if intensity_percent is None else intensity_percent, + ) + + async def set_brightfield_enabled(self, enabled: bool) -> None: + """Turn the configured brightfield illumination on or off.""" + channel = self._require_channel_config("brightfield") + strobe = self._find_digital_io("FLOnOff") + if strobe is not None: + await self.set_digital_output(strobe.bit_index, strobe.invert) + await self._set_channel_intensity(channel, channel.intensity_percent if enabled else 0.0) + + @property + def fluorescence_warmup_remaining(self) -> float: + """Seconds remaining in the configured fluorescence-lamp warm-up interval.""" + on_since = self._fluorescence_on_since + if on_since is None: + return self.fluorescence_warmup_seconds + elapsed = time.monotonic() - on_since + return max(0.0, self.fluorescence_warmup_seconds - elapsed) + + @property + def fluorescence_lamp_ready(self) -> bool: + return self.fluorescence_warmup_remaining <= 0 + + @property + def can_change_fluorescence_power(self) -> bool: + last_change = self._last_fluorescence_power_change + if last_change is None: + return True + return bool(time.monotonic() - last_change >= self.fluorescence_power_change_interval) + + async def set_fluorescence_lamp_power(self, enabled: bool) -> None: + """Set lamp power while enforcing the vendor's minimum toggle interval. + + Instruments without a configured ``ExcitationLampPower`` output have an + always-powered source; requesting ``False`` is rejected because there is no line to + switch it. + """ + output = self._find_digital_io("ExcitationLampPower") + if output is None: + if not enabled: + raise CeligoError("This instrument has no controllable fluorescence-lamp power line") + if self._fluorescence_on_since is None: + self._fluorescence_on_since = 0.0 + return + if enabled == (self._fluorescence_on_since is not None): + return + if not self.can_change_fluorescence_power: + remaining = self.fluorescence_power_change_interval - ( + time.monotonic() - cast(float, self._last_fluorescence_power_change) + ) + raise CeligoError(f"Fluorescence lamp cannot change power for {remaining:.1f}s") + if not enabled: + await self._set_named_digital_output("FLOnOff", False) + await self.set_digital_output(output.bit_index, enabled != output.invert) + now = time.monotonic() + self._fluorescence_on_since = now if enabled else None + self._last_fluorescence_power_change = now + + async def turn_off_illumination(self) -> None: + """Turn off every configured illumination output and fluorescence strobe.""" + hardware = self.config.hardware + if hardware.io is None: + raise CeligoError("Celigo IO configuration is missing") + first_error: Optional[BaseException] = None + + async def attempt(operation: Awaitable[None]) -> None: + nonlocal first_error + try: + await operation + except BaseException as exc: + if first_error is None: + first_error = exc + + strobe = self._find_digital_io("FLOnOff") + if strobe is not None: + await attempt(self.set_digital_output(strobe.bit_index, strobe.invert)) + for output in hardware.io.lighting_ios: + if output.enabled: + await attempt(self._set_lighting_output_intensity(output, 0.0)) + if first_error is not None: + raise first_error + + async def select_channel( + self, + channel: IlluminationChannelName, + require_lamp_ready: bool = False, + ) -> None: + """Select an imaging channel while leaving its illumination off. + + Drops the strobe and all lighting outputs before moving the dichroic filter wheel, + centering the galvos, and setting the fluorescence lamp-select bits. Call + :meth:`set_illumination_enabled` to turn on the selected channel. + + Moves the filter wheel (hardware motion). The power toggle interval is enforced; + pass ``require_lamp_ready=True`` to enforce the configured warm-up interval too. + """ + channel_config = self._require_channel_config(channel) + self._require_lighting_io(channel_config.lighting_io_name) + if channel_config.strobe: + self._require_digital_io("FLOnOff") + if channel_config.bit_value is not None: + self._require_digital_io("FLBit0") + self._require_digital_io("FLBit1") + self.current_channel = None + await self.turn_off_illumination() + if channel_config.strobe: + await self.set_fluorescence_lamp_power(True) + if require_lamp_ready and not self.fluorescence_lamp_ready: + raise CeligoError( + f"Fluorescence lamp is warming up ({self.fluorescence_warmup_remaining:.1f}s left)" + ) + await self.dichroic_filter.move_to(channel_config.logical_filter) + hardware = self.config.hardware + if ( + hardware.x_galvo is not None + and hardware.x_galvo.enabled + and hardware.y_galvo is not None + and hardware.y_galvo.enabled + ): + await self.galvo.home(logical_filter=channel_config.logical_filter) + if channel_config.bit_value is not None: + # The vendor's BitValue orders the two physical selector lines MSB first. + await self._set_named_digital_output("FLBit0", bool(channel_config.bit_value & 0b10)) + await self._set_named_digital_output("FLBit1", bool(channel_config.bit_value & 0b01)) + + self.current_channel = channel + + async def set_illumination_enabled( + self, + enabled: bool, + intensity_percent: Optional[float] = None, + ) -> None: + """Turn the selected channel on or off using a percentage intensity override.""" + if not enabled: + await self.turn_off_illumination() + return + if self.current_channel is None: + raise CeligoError("Select an imaging channel before enabling illumination") + channel_config = self._require_channel_config(self.current_channel) + strobe = self._find_digital_io("FLOnOff") + if channel_config.strobe: + if strobe is None: + raise CeligoError("Fluorescence channel requires the FLOnOff digital output") + await self._set_channel_intensity(channel_config, intensity_percent) + await self.set_digital_output(strobe.bit_index, not strobe.invert) + else: + if strobe is not None: + await self.set_digital_output(strobe.bit_index, strobe.invert) + await self._set_channel_intensity(channel_config, intensity_percent) + + # -- autofocus ------------------------------------------------------------- + + def _validate_camera_geometry(self) -> None: + """Reject a camera format that disagrees with the optical calibration geometry.""" + calibration = self.config.calibration + width = self.camera.width + height = self.camera.height + if not isinstance(width, int) or not isinstance(height, int) or width <= 0 or height <= 0: + raise CeligoError("Camera must expose positive width and height for geometry validation") + expected = (calibration.image_width_pixels, calibration.image_height_pixels) + if (width, height) != expected: + raise CeligoError( + f"Camera format {width}x{height} does not match calibrated image geometry " + f"{expected[0]}x{expected[1]}; configure the Lumenera ROI before acquisition" + ) + + async def _configure_camera_for_calibration(self) -> None: + """Apply the calibrated image dimensions to cameras that support native ROI.""" + calibration = self.config.calibration + camera = self.camera + expected = (calibration.image_width_pixels, calibration.image_height_pixels) + if (camera.width, camera.height) == expected: + return + await camera.set_frame_format(*expected) + self._validate_camera_geometry() + + def _validate_frame_integrity(self, frame: CameraFrame) -> None: + if frame.width <= 0 or frame.height <= 0 or frame.bit_depth not in (8, 16): + raise CeligoError( + f"Camera returned invalid frame metadata {frame.width}x{frame.height}x{frame.bit_depth}" + ) + expected_bytes = frame.width * frame.height * (2 if frame.bit_depth == 16 else 1) + if len(frame.data) != expected_bytes: + raise CeligoError( + f"Camera returned {len(frame.data)} bytes; {expected_bytes} are required by " + f"the {frame.width}x{frame.height}x{frame.bit_depth} format" + ) + + def _validate_frame_geometry(self, frame: CameraFrame) -> None: + self._validate_frame_integrity(frame) + calibration = self.config.calibration + expected = (calibration.image_width_pixels, calibration.image_height_pixels) + if (frame.width, frame.height) != expected: + raise CeligoError( + f"Captured frame {frame.width}x{frame.height} does not match calibrated " + f"geometry {expected[0]}x{expected[1]}" + ) + + async def _ensure_camera_ready( + self, + require_calibrated_geometry: bool = True, + ) -> CeligoCamera: + camera = self.camera + if not camera.is_open: + await camera.setup() + if require_calibrated_geometry: + self._validate_camera_geometry() + return camera + + async def set_camera_exposure_and_gain( + self, + exposure_ms: Optional[float] = None, + gain: Optional[float] = None, + restart_camera_stream: bool = False, + ) -> Tuple[float, float]: + """Set camera properties through the Celigo-owned camera lifecycle. + + ``restart_camera_stream=True`` closes and reopens the stream after applying settings. + """ + camera = await self._ensure_camera_ready(require_calibrated_geometry=False) + if exposure_ms is not None: + await camera.set_exposure(exposure_ms) + if gain is not None: + await camera.set_gain(gain) + if restart_camera_stream: + await camera.stop() + await camera.setup() + await self._configure_camera_for_calibration() + return camera.exposure_ms, camera.gain + + async def capture_frame( + self, + exposure_ms: Optional[float] = None, + gain: Optional[float] = None, + flush_frames: int = 2, + ) -> CameraFrame: + """Capture one image from the configured camera.""" + camera = await self._ensure_camera_ready() + if exposure_ms is not None: + await camera.set_exposure(exposure_ms) + if gain is not None: + await camera.set_gain(gain) + frame = await camera.capture(flush_frames=flush_frames) + self._validate_frame_geometry(frame) + return frame + + async def auto_exposure( + self, + candidates_ms: Tuple[float, ...] = (20.0, 10.0, 5.0, 2.0, 1.0, 0.5, 0.25, 0.1), + saturation_fraction: float = 0.01, + minimum_mean_fraction: float = 0.03, + ) -> Tuple[float, CameraFrame]: + """Select the longest candidate exposure that is bright but not saturated.""" + if not candidates_ms or any(candidate <= 0 for candidate in candidates_ms): + raise ValueError("candidates_ms must contain positive exposures") + camera = await self._ensure_camera_ready() + selected: Optional[Tuple[float, CameraFrame]] = None + for exposure in candidates_ms: + await camera.set_exposure(exposure) + frame = await camera.capture(flush_frames=3) + self._validate_frame_geometry(frame) + values = frame.pixels() + maximum_value = 65535 if frame.bit_depth > 8 else 255 + hot_threshold = maximum_value - max(1, maximum_value // 50) + hot = sum(1 for value in values if value >= hot_threshold) / max(1, len(values)) + mean = sum(values) / max(1, len(values)) + selected = (exposure, frame) + if hot <= saturation_fraction and mean >= maximum_value * minimum_mean_fraction: + return selected + if selected is None: + raise CeligoError("Auto-exposure produced no camera frames") + return selected + + async def autofocus( + self, + autofocus_method: AutofocusMethod = "image", + center_z_ticks: Optional[int] = None, + span_ticks: Optional[int] = None, + coarse_step_ticks: Optional[int] = None, + fine_step_ticks: int = 76, + evaluator: Optional[Callable[[CameraFrame], float]] = None, + settle_seconds: float = 0.05, + focus_flush_frames: int = 2, + verification_attempts: int = 2, + minimum_verification_ratio: float = 0.7, + ) -> FocusResult: + """Find focus by host-side Z stepping, as observed in the Celigo captures. + + Image autofocus uses the variance-of-Laplacian metric supplied by + :class:`CameraFrame`, or a custom image evaluator. + """ + if autofocus_method != "image": + raise ValueError("autofocus_method must be 'image'") + if fine_step_ticks <= 0: + raise ValueError("fine_step_ticks must be positive") + if focus_flush_frames < 0: + raise ValueError("focus_flush_frames must be non-negative") + if verification_attempts < 1: + raise ValueError("verification_attempts must be at least 1") + if not 0 < minimum_verification_ratio <= 1: + raise ValueError("minimum_verification_ratio must be in (0, 1]") + z_axis = self.z_axis + minimum_z_ticks, maximum_z_ticks = z_axis.encoder_bounds() + selected_span_ticks = span_ticks if span_ticks is not None else 3000 + selected_coarse_step_ticks = 252 if coarse_step_ticks is None else coarse_step_ticks + if selected_span_ticks < 0 or selected_coarse_step_ticks <= 0: + raise ValueError("span_ticks must be non-negative and coarse_step_ticks positive") + initial_z_ticks = await z_axis.request_encoder_ticks() + if center_z_ticks is None: + center_z_ticks = initial_z_ticks + center_z_ticks = min( + maximum_z_ticks, + max(minimum_z_ticks, center_z_ticks), + ) + + score_frame = evaluator or (lambda frame: frame.sharpness()) + scored_z_samples: List[Tuple[int, float]] = [] + inspected_z_ticks: set[int] = set() + + def evaluate(frame: CameraFrame) -> float: + score = float(score_frame(frame)) + if not math.isfinite(score): + raise CeligoError("Autofocus evaluator returned a non-finite score") + return score + + async def inspect(z_ticks: int) -> None: + z_ticks = min(maximum_z_ticks, max(minimum_z_ticks, z_ticks)) + if z_ticks in inspected_z_ticks: + return + try: + await z_axis.move_to_ticks(z_ticks) + if settle_seconds > 0: + await asyncio.sleep(settle_seconds) + frame = await self.capture_frame(flush_frames=focus_flush_frames) + score = evaluate(frame) + inspected_z_ticks.add(z_ticks) + scored_z_samples.append((z_ticks, score)) + except BaseException: + with contextlib.suppress(Exception): + await complete_cleanup(z_axis.move_to_ticks(initial_z_ticks)) + raise + + async def run_scan() -> FocusResult: + coarse_start_z_ticks = max( + minimum_z_ticks, + center_z_ticks - selected_span_ticks, + ) + coarse_stop_z_ticks = min( + maximum_z_ticks, + center_z_ticks + selected_span_ticks, + ) + coarse_z_positions = list( + range( + coarse_start_z_ticks, + coarse_stop_z_ticks + 1, + selected_coarse_step_ticks, + ) + ) + if not coarse_z_positions or coarse_z_positions[-1] != coarse_stop_z_ticks: + coarse_z_positions.append(coarse_stop_z_ticks) + for z_ticks in coarse_z_positions: + await inspect(z_ticks) + + best_z_ticks = max(scored_z_samples, key=lambda item: item[1])[0] + fine_start_z_ticks = max( + minimum_z_ticks, + best_z_ticks - selected_coarse_step_ticks, + ) + fine_stop_z_ticks = min( + maximum_z_ticks, + best_z_ticks + selected_coarse_step_ticks, + ) + fine_z_positions = list(range(fine_start_z_ticks, fine_stop_z_ticks + 1, fine_step_ticks)) + if not fine_z_positions or fine_z_positions[-1] != fine_stop_z_ticks: + fine_z_positions.append(fine_stop_z_ticks) + for z_ticks in fine_z_positions: + await inspect(z_ticks) + + best_z_ticks, best_score = max(scored_z_samples, key=lambda item: item[1]) + focus_scores = [score for _, score in scored_z_samples] + if max(focus_scores) - min(focus_scores) <= max( + 1e-12, + abs(max(focus_scores)) * 1e-9, + ): + await z_axis.move_to_ticks(initial_z_ticks) + raise CeligoError("Autofocus scan has no measurable focus contrast") + if best_z_ticks in ( + min(z_ticks for z_ticks, _ in scored_z_samples), + max(z_ticks for z_ticks, _ in scored_z_samples), + ): + await z_axis.move_to_ticks(initial_z_ticks) + raise CeligoError("Autofocus optimum lies on the scan boundary") + try: + await z_axis.move_to_ticks(best_z_ticks) + final_frame: Optional[CameraFrame] = None + final_score = -math.inf + for _ in range(verification_attempts): + candidate_frame = await self.capture_frame(flush_frames=max(2, focus_flush_frames)) + candidate_score = evaluate(candidate_frame) + if candidate_score > final_score: + final_frame = candidate_frame + final_score = candidate_score + if final_score >= best_score * minimum_verification_ratio: + break + if final_frame is None or final_score < best_score * minimum_verification_ratio: + raise CeligoError( + "Autofocus optimum did not reproduce after the final Z move: " + f"scan score {best_score:.6g}, verification score {final_score:.6g}, " + f"required ratio {minimum_verification_ratio:.3f}" + ) + except BaseException: + with contextlib.suppress(Exception): + await complete_cleanup(z_axis.move_to_ticks(initial_z_ticks)) + raise + return FocusResult( + z_ticks=best_z_ticks, + z_mm=z_axis.encoder_ticks_to_mm(best_z_ticks), + score=final_score, + scored_z_samples=tuple(scored_z_samples), + frame=final_frame, + ) + + return await run_scan() + + async def _acquire_field( + self, + well: str, + channel: IlluminationChannelName, + exposure_ms: Optional[float] = None, + gain: Optional[float] = None, + autofocus: Optional[AutofocusMethod] = None, + z_mm: Optional[float] = None, + require_lamp_ready: bool = False, + galvo_offset_mm: Tuple[float, float] = (0.0, 0.0), + machine_auto_exposure: bool = False, + positioned_stage_mm: Optional[Tuple[float, float]] = None, + ) -> AcquisitionResult: + """Navigate, select optics, optionally focus, and capture one calibrated FOV.""" + if autofocus not in (None, "image"): + raise ValueError("autofocus must be None or 'image'") + if positioned_stage_mm is None: + x_mm, y_mm = await self.move_to_well(well, retract_z=True) + else: + x_mm, y_mm = positioned_stage_mm + channel_config = self._require_channel_config(channel) + target_z_mm = z_mm + if target_z_mm is None: + target_z_mm = ( + self.config.calibration.calibrated_z_position + channel_config.z_offset_to_brightfield_mm + ) + settled_z_mm = await self.z_axis.move_to(target_z_mm) + await self.select_channel(channel, require_lamp_ready=require_lamp_ready) + logical_filter = channel_config.logical_filter + logical_galvo_voltages = self.galvo.voltages_for_offset( + logical_filter, + galvo_offset_mm, + ) + galvo_hardware_voltages = await self.galvo.move_both(*logical_galvo_voltages) + + camera = await self._ensure_camera_ready() + if exposure_ms is not None: + await camera.set_exposure(exposure_ms) + if gain is not None: + await camera.set_gain(gain) + await self.set_illumination_enabled(True) + if machine_auto_exposure: + await self.auto_exposure() + + focus_result: Optional[FocusResult] = None + if autofocus is not None: + focus_result = await self.autofocus( + autofocus_method=autofocus, + center_z_ticks=self.z_axis.mm_to_encoder_ticks(settled_z_mm), + ) + frame = focus_result.frame if focus_result is not None else await camera.capture(flush_frames=2) + self._validate_frame_geometry(frame) + return AcquisitionResult( + label=well, + channel=channel, + x_mm=x_mm, + y_mm=y_mm, + z_mm=focus_result.z_mm if focus_result is not None else settled_z_mm, + frame=frame, + focus=focus_result, + galvo_hardware_voltages=galvo_hardware_voltages, + ) + + async def acquire( + self, + well: str, + channel: IlluminationChannelName, + exposure_ms: Optional[float] = None, + gain: Optional[float] = None, + autofocus: Optional[AutofocusMethod] = None, + z_mm: Optional[float] = None, + require_lamp_ready: bool = False, + galvo_offset_mm: Tuple[float, float] = (0.0, 0.0), + machine_auto_exposure: bool = False, + ) -> AcquisitionResult: + """Acquire one FOV and extinguish illumination before returning.""" + try: + result = await self._acquire_field( + well=well, + channel=channel, + exposure_ms=exposure_ms, + gain=gain, + autofocus=autofocus, + z_mm=z_mm, + require_lamp_ready=require_lamp_ready, + galvo_offset_mm=galvo_offset_mm, + machine_auto_exposure=machine_auto_exposure, + ) + except BaseException: + with contextlib.suppress(Exception): + await complete_cleanup(self.turn_off_illumination()) + raise + await complete_cleanup(self.turn_off_illumination()) + return result + + async def _acquire_scan_position( + self, + position: ScanPosition, + label: str, + channel: IlluminationChannelName, + exposure_ms: Optional[float], + gain: Optional[float], + autofocus: Optional[AutofocusMethod], + z_mm: Optional[float], + settled_stage_position_mm: Tuple[float, float], + machine_auto_exposure: bool = False, + ) -> AcquisitionResult: + """Acquire one compiled scan position and extinguish illumination afterward.""" + try: + result = await self._acquire_field( + well=label, + channel=channel, + exposure_ms=exposure_ms, + gain=gain, + autofocus=autofocus, + z_mm=z_mm, + galvo_offset_mm=( + position.galvo_offset_x_mm, + position.galvo_offset_y_mm, + ), + positioned_stage_mm=settled_stage_position_mm, + machine_auto_exposure=machine_auto_exposure, + ) + except BaseException: + with contextlib.suppress(Exception): + await complete_cleanup(self.turn_off_illumination()) + raise + await complete_cleanup(self.turn_off_illumination()) + return result + + async def _move_to_scan_block(self, block: ScanBlock) -> Tuple[float, float]: + """Move safely to one compiled coarse stage position.""" + await self.turn_off_illumination() + await self.z_axis.move_to(self.z_axis.config.min_position) + settled_x_mm = await self.x_axis.move_to(block.stage_x_mm) + settled_y_mm = await self.y_axis.move_to(block.stage_y_mm) + return settled_x_mm, settled_y_mm + + def plan( + self, + spec: ScanSpec, + *, + estimate_model: Optional[ScanEstimateModel] = None, + ) -> ScanPlan: + """Compile a complete scan specification without moving hardware.""" + return build_scan_plan( + self.config, + spec, + estimate_model=estimate_model, + ) + + async def execute(self, plan: ScanPlan) -> ScanResult: + """Execute the exact frame operations in a compiled scan plan.""" + return await self._execute_scan_plan(plan) + + async def _execute_scan_plan( + self, + plan: ScanPlan, + on_frame: Optional[Callable[[FrameResult], Awaitable[None]]] = None, + initial_brightfield_z_mm: Optional[float] = None, + ) -> ScanResult: + """Execute a plan, optionally reporting each completed frame to an internal consumer.""" + if not isinstance(plan, ScanPlan): + raise TypeError("plan must be a ScanPlan") + if not plan.matches_configuration(self.config): + raise ValueError( + "ScanPlan configuration does not match this Celigo; rebuild it with celigo.plan(...)" + ) + + started_at = time.monotonic() + frame_results: List[FrameResult] = [] + frames_by_block: Dict[int, List[PlannedFrame]] = {block.index: [] for block in plan.blocks} + for planned_frame in plan.frames: + block_frames = frames_by_block.get(planned_frame.block.index) + if block_frames is None: + raise ValueError( + f"Planned frame {planned_frame.index} references unknown block " + f"{planned_frame.block.index}" + ) + block_frames.append(planned_frame) + + for block in plan.blocks: + settled_stage_position_mm = await self._move_to_scan_block(block) + focused_brightfield_z_mm = initial_brightfield_z_mm + label = block.label if block.label is not None else f"block-{block.index}" + for frame_index, planned_frame in enumerate(frames_by_block[block.index]): + capture = planned_frame.capture + channel_config = self._require_channel_config(capture.channel) + channel_z_mm = ( + None + if focused_brightfield_z_mm is None + else focused_brightfield_z_mm + channel_config.z_offset_to_brightfield_mm + ) + acquisition = await self._acquire_scan_position( + position=planned_frame.position, + label=label, + channel=capture.channel, + exposure_ms=capture.exposure_ms, + gain=capture.gain, + autofocus=plan.spec.autofocus if frame_index == 0 else None, + z_mm=channel_z_mm, + settled_stage_position_mm=settled_stage_position_mm, + ) + if acquisition.focus is not None: + focused_brightfield_z_mm = acquisition.z_mm - channel_config.z_offset_to_brightfield_mm + frame_result = FrameResult( + planned=planned_frame, + frame=acquisition.frame, + actual_stage_mm=(acquisition.x_mm, acquisition.y_mm), + actual_z_mm=acquisition.z_mm, + galvo_hardware_voltages=acquisition.galvo_hardware_voltages, + focus=acquisition.focus, + ) + frame_results.append(frame_result) + if on_frame is not None: + await on_frame(frame_result) + + return ScanResult( + plan=plan, + frames=tuple(frame_results), + elapsed=timedelta(seconds=time.monotonic() - started_at), + ) + + async def scan( + self, + spec: ScanSpec, + *, + estimate_model: Optional[ScanEstimateModel] = None, + ) -> ScanResult: + """Compile and execute a scan specification.""" + return await self.execute(self.plan(spec, estimate_model=estimate_model)) + + async def scan_wells( + self, + plate: Plate, + wells: Sequence[str], + *, + channel: str, + block_shape: BlockShape = (1, 1), + exposure_ms: Optional[float] = None, + gain: Optional[float] = None, + autofocus: Optional[AutofocusMethod] = None, + estimate_model: Optional[ScanEstimateModel] = None, + ) -> ScanResult: + """Scan named wells with one capture per planned position.""" + spec = ScanSpec.wells( + plate, + wells, + block_shape=block_shape, + channel=channel, + exposure_ms=exposure_ms, + gain=gain, + autofocus=autofocus, + ) + return await self.scan(spec, estimate_model=estimate_model) + + # -- camera synchronization ------------------------------------------------- + + async def _send_signal_diagnostic_command(self, diagnostic_operation: int) -> int: + """Send a SIGNAL_DIAGNOSTICS sub-command and return its int result.""" + response = await self.send_command( + _CMD_SIGNAL_DIAGNOSTICS, + struct.pack(">h", diagnostic_operation), + ) + validate_payload_length(response, 4, "signal diagnostics") + return int(struct.unpack_from(">i", response, 0)[0]) + + async def set_camera_trigger_line(self, asserted: bool) -> None: + """Assert or clear the camera trigger line.""" + await self._send_signal_diagnostic_command( + _DIAG_SET_TRIGGER if asserted else _DIAG_CLEAR_TRIGGER + ) + + async def pulse_camera_trigger(self) -> None: + """Pulse the camera trigger line once.""" + await self._send_signal_diagnostic_command(_DIAG_PULSE_TRIGGER) + + @staticmethod + def _decode_camera_signal(raw_value: int, inverted: bool) -> Optional[bool]: + """Decode a camera input, returning ``None`` when firmware reports it unavailable.""" + if raw_value not in (0, 1): + return None + return bool(raw_value) != inverted + + async def request_is_camera_busy(self) -> Optional[bool]: + """Read the polarity-corrected camera busy signal, or ``None`` when unavailable.""" + camera_config = self.config.hardware.external_camera_control + inverted = camera_config.invert_busy if camera_config is not None else False + return self._decode_camera_signal( + await self._send_signal_diagnostic_command(_DIAG_READ_BUSY), + inverted, + ) + + async def request_is_camera_integrating(self) -> Optional[bool]: + """Read the camera integration signal, or ``None`` when unavailable.""" + camera_config = self.config.hardware.external_camera_control + inverted = camera_config.invert_integration if camera_config is not None else False + return self._decode_camera_signal( + await self._send_signal_diagnostic_command(_DIAG_READ_INTEGRATION), + inverted, + ) + + async def request_camera_trigger_encoder_ticks(self) -> int: + """Read the encoder captured by the camera-trigger diagnostics path.""" + return await self._send_signal_diagnostic_command(_DIAG_READ_ENCODER) + + # -- diagnostics ----------------------------------------------------------- + + async def run_self_test( + self, + run_active_checks: bool = False, + run_motion_checks: bool = False, + ) -> SelfTestReport: + """Run controller diagnostics; hardware-changing checks require explicit opt-in. + + The default is read-only. ``run_active_checks=True`` centers the galvos and captures + a camera frame when configured. ``run_motion_checks=True`` additionally performs a + five-tick round-trip on X/Y/Z and therefore requires a clear motion envelope. + """ + if run_motion_checks and not run_active_checks: + raise ValueError("run_motion_checks requires run_active_checks=True") + + checks: Dict[str, Any] = {} + failures: List[str] = [] + + async def record( + check_name: str, + check: Callable[[], Awaitable[Any]], + ) -> None: + try: + checks[check_name] = await check() + except Exception as exc: # diagnostics must report every check + checks[check_name] = f"{type(exc).__name__}: {exc}" + failures.append(check_name) + + async def check_motor_encoder_ratio( + axis: Axis, + check_name: str, + ) -> Dict[str, Any]: + actual_ratio = await axis.request_encoder_ratio() + expected_ratio = axis.config.encoder_to_motor_tick_ratio + matches = math.isclose( + actual_ratio, + expected_ratio, + rel_tol=0.0, + abs_tol=0.0005, + ) + if not matches: + failures.append(check_name) + return { + "actual": actual_ratio, + "expected": expected_ratio, + "matches": matches, + } + + async def run_motion_round_trip(axis_name: LinearAxisName) -> Dict[str, int]: + axis = self._require_linear_axis(axis_name) + start_encoder_ticks = await axis.request_encoder_ticks() + minimum_encoder_ticks, maximum_encoder_ticks = axis.encoder_bounds() + if not minimum_encoder_ticks <= start_encoder_ticks <= maximum_encoder_ticks: + raise CeligoError( + f"Diagnostic {axis_name} start {start_encoder_ticks} is outside configured " + f"bounds {minimum_encoder_ticks}..{maximum_encoder_ticks}" + ) + target_encoder_ticks = ( + start_encoder_ticks + 5 + if start_encoder_ticks + 5 <= maximum_encoder_ticks + else start_encoder_ticks - 5 + ) + if not minimum_encoder_ticks <= target_encoder_ticks <= maximum_encoder_ticks: + raise CeligoError( + f"No safe five-tick diagnostic move from {axis_name}={start_encoder_ticks}" + ) + try: + await axis.move_to_ticks(target_encoder_ticks) + finally: + # Always attempt restoration, including cancellation or a failed outward move. + restoration = asyncio.create_task(axis.move_to_ticks(start_encoder_ticks)) + try: + end_encoder_ticks = await asyncio.shield(restoration) + except asyncio.CancelledError: + with contextlib.suppress(Exception): + await restoration + raise + return { + "start": start_encoder_ticks, + "end": end_encoder_ticks, + } + + async def request_encoder_positions() -> Dict[str, int]: + return { + axis.name: await axis.request_encoder_ticks() for axis in self._configured_motion_axes() + } + + await record("controller_status", self.request_controller_status) + status = checks.get("controller_status") + if isinstance(status, ControllerStatus) and status.has_controller_fault: + failures.append("controller_status") + await record("controller_info", self.request_controller_info) + await record("motor_map", self.request_detected_motor_addresses) + await record("encoders", request_encoder_positions) + await record("digital_inputs", self.request_digital_input_bitmask) + + for logical_filter, transform in sorted(self.config.galvo_calibrations.items()): + check_name = f"galvo_calibration_{logical_filter}" + checks[check_name] = transform.successful + if transform.successful is False: + failures.append(check_name) + + for axis in self._configured_motion_axes(): + check_name = f"motor_{axis.axis_index}_encoder_ratio" + await record( + check_name, + partial(check_motor_encoder_ratio, axis, check_name), + ) + + camera_config = self.config.hardware.external_camera_control + if camera_config is not None and camera_config.enabled: + await record("camera_busy", self.request_is_camera_busy) + await record("camera_integration", self.request_is_camera_integrating) + + if run_active_checks: + await record("galvo_center", self.galvo.home) + await record("camera_frame", self.capture_frame) + if run_motion_checks: + for axis_name in _LINEAR_AXIS_NAMES: + await record( + f"{axis_name}_motion_round_trip", + partial(run_motion_round_trip, axis_name), + ) + + # Preserve first occurrence while making the report deterministic. + unique_failures = tuple(dict.fromkeys(failures)) + return SelfTestReport(not unique_failures, checks, unique_failures) diff --git a/pylabrobot/revvity/celigo/config.py b/pylabrobot/revvity/celigo/config.py new file mode 100644 index 00000000000..da1e3d68ba6 --- /dev/null +++ b/pylabrobot/revvity/celigo/config.py @@ -0,0 +1,1302 @@ +"""Typed configuration for the Celigo USB-IO controller, with an XML loader. + +Per-machine hardware configuration is stored as XML under ``/ConfigFiles/`` +(e.g. ``USBIOHardwareConfig.config``). Those files are the authoritative, per-instrument +source of truth for axis tuning, galvo/filter-wheel setup, and the analog/digital IO map. + +This module mirrors that schema as nested dataclasses. Two ways to obtain a config: + +* :meth:`CeligoConfig.from_install` — locate the Celigo ``ConfigFiles`` directory once + and load the complete per-instrument configuration. +* Construct :class:`CeligoConfig` and its typed subobjects directly — for users who want + to specify everything in code, or override individual values after loading. + +The :class:`~pylabrobot.revvity.celigo.Celigo` constructor accepts a complete +:class:`CeligoConfig`, or loads one with :meth:`CeligoConfig.from_install`. +""" + +from __future__ import annotations + +import math +import os +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +_HARDWARE_CONFIG_FILENAME = "USBIOHardwareConfig.config" +_CONFIG_SUBDIRECTORIES = ( + "", + "ConfigFiles", + os.path.join("Celigo", "ConfigFiles"), + os.path.join("Nexcelom Bioscience", "Celigo", "ConfigFiles"), + os.path.join("Nexcelom", "Celigo", "ConfigFiles"), + os.path.join("Cyntellect", "Celigo", "ConfigFiles"), +) + + +def _locate_hardware_config_file(install_dir: str) -> Optional[str]: + """Locate the one hardware file that establishes the complete config directory.""" + root = install_dir + if os.path.isfile(root): + if os.path.basename(root).lower() == _HARDWARE_CONFIG_FILENAME.lower(): + return root + root = os.path.dirname(root) + for subdirectory in _CONFIG_SUBDIRECTORIES: + directory = os.path.join(root, subdirectory) + exact_path = os.path.join(directory, _HARDWARE_CONFIG_FILENAME) + if os.path.isfile(exact_path): + return exact_path + if not os.path.isdir(directory): + continue + case_insensitive_match = next( + ( + filename + for filename in os.listdir(directory) + if filename.lower() == _HARDWARE_CONFIG_FILENAME.lower() + ), + None, + ) + if case_insensitive_match is not None: + return os.path.join(directory, case_insensitive_match) + return None + + +def _xml_local_name(tag: str) -> str: + """Strip the ``{namespace}`` prefix ElementTree prepends to tags.""" + return tag.rsplit("}", 1)[-1] + + +def _leaf_scalars(element: ET.Element) -> Dict[str, str]: + """Map ``localname -> text`` for the direct leaf children of ``element``.""" + out: Dict[str, str] = {} + for child in element: + if len(child) == 0 and child.text is not None and child.text.strip(): + out[_xml_local_name(child.tag)] = child.text.strip() + return out + + +def _all_leaf_scalars(root: ET.Element) -> Dict[str, str]: + """Collect every leaf ``localname -> text`` in the document. + + Used for the flat ``
`` DataContract files that hold a + single object (CalibrationConfig, HardwareDefaultConfig). Last value wins on duplicate + tag names, which is fine for these single-object files. + """ + out: Dict[str, str] = {} + for el in root.iter(): + if len(el) == 0 and el.text is not None and el.text.strip(): + out[_xml_local_name(el.tag)] = el.text.strip() + return out + + +class _XmlScalars: + """Typed, explicit access to one XML object's leaf values.""" + + def __init__(self, scalars: Dict[str, str]) -> None: + self._scalars = scalars + self._recognized_tags: set[str] = set() + + def text(self, *tags: str) -> str: + self._recognized_tags.update(tags) + for tag in tags: + value = self._scalars.get(tag) + if value is not None and value.strip(): + return value.strip() + raise ValueError(f"Configuration is missing required field {tags[0]}") + + def integer(self, *tags: str) -> int: + # Vendor files sometimes serialize integral settings as ``256.0``. + value = float(self.text(*tags)) + if not math.isfinite(value) or not value.is_integer(): + raise ValueError(f"Configuration field {tags[0]} must be an integer") + return int(value) + + def integer_or(self, tag: str, fallback: int) -> int: + self._recognized_tags.add(tag) + value = self._scalars.get(tag) + if value is None: + return fallback + parsed = float(value) + if not math.isfinite(parsed) or not parsed.is_integer(): + raise ValueError(f"Configuration field {tag} must be an integer") + return int(parsed) + + def floating(self, *tags: str) -> float: + return float(self.text(*tags)) + + def boolean(self, *tags: str) -> bool: + value = self.text(*tags) + normalized = value.lower() + if normalized not in ("true", "false"): + raise ValueError(f"Invalid boolean value {value!r}") + return normalized == "true" + + def unrecognized(self) -> Dict[str, str]: + return {tag: value for tag, value in self._scalars.items() if tag not in self._recognized_tags} + + +@dataclass(frozen=True) +class _AxisXmlValues: + motion_name: str + config_version: int + motor_type: int + comm_index: int + controller_index: int + axis_index: int + enabled: bool + max_velocity: float + max_acceleration: float + max_deceleration: float + max_s_acceleration: int + moderate_acceleration: float + minimum_acceleration: float + moderate_s_acceleration: int + minimum_s_acceleration: int + s_curve_support: bool + home_type: str + homing_velocity: float + index_velocity: float + homing_short_move: int + home_offset: float + positive_limit: bool + negative_limit: bool + limit_polarity: int + invert_axis_direction: bool + default_positive_direction: bool + moving_current_percentage: int + holding_current_percentage: int + loading_current_percentage: int + moving_overload_limit: int + mode_enable_limits: bool + mode_enable_step_and_direction: bool + mode_enable_position_correction: bool + mode_enable_motor_slave_to_encoder: bool + coarse_position_error_window: int + fine_position_error_window: int + gain: int + encoder_to_motor_tick_ratio: float + backlash_compensation: int + motor_response_time: int + + +def _read_axis_values(reader: _XmlScalars, limit_polarity: int) -> _AxisXmlValues: + return _AxisXmlValues( + motion_name=reader.text("MotionName"), + config_version=reader.integer("ConfigVersion"), + motor_type=reader.integer("MotorType"), + comm_index=reader.integer("CommIndex"), + controller_index=reader.integer("ControllerIndex"), + axis_index=reader.integer("AxisIndex"), + enabled=reader.boolean("Enabled"), + max_velocity=reader.floating("MaxVelocity"), + max_acceleration=reader.floating("MaxAcceleration"), + max_deceleration=reader.floating("MaxDeceleration"), + max_s_acceleration=reader.integer("MaxSAcceleration"), + moderate_acceleration=reader.floating( + "ModerateAccleration", + "ModerateAcceleration", + ), + minimum_acceleration=reader.floating("MinimumAcceleration"), + moderate_s_acceleration=reader.integer("ModerateSAcceleration"), + minimum_s_acceleration=reader.integer("MinimumSAcceleration"), + s_curve_support=reader.boolean("SCurveSupport"), + home_type=reader.text("HomeType"), + homing_velocity=reader.floating("HomingVelocity"), + index_velocity=reader.floating("IndexVelocity"), + homing_short_move=reader.integer("HomingShortMove"), + home_offset=reader.floating("HomeOffset"), + positive_limit=reader.boolean("PositiveLimit"), + negative_limit=reader.boolean("NegativeLimit"), + limit_polarity=limit_polarity, + invert_axis_direction=reader.boolean("InvertAxisDirection"), + default_positive_direction=reader.boolean("DefaultPositiveDirection"), + moving_current_percentage=reader.integer("MovingCurrentPercentage"), + holding_current_percentage=reader.integer("HoldingCurrentPercentage"), + loading_current_percentage=reader.integer("LoadingCurrentPercentage"), + moving_overload_limit=reader.integer("MovingOverloadLimit"), + mode_enable_limits=reader.boolean("Mode_EnableLimits"), + mode_enable_step_and_direction=reader.boolean("Mode_EnableStepAndDirection"), + mode_enable_position_correction=reader.boolean("Mode_EnablePositionCorrection"), + mode_enable_motor_slave_to_encoder=reader.boolean("Mode_EnableMotorSlaveToEncoder"), + coarse_position_error_window=reader.integer("CoursePositionErrorWindow"), + fine_position_error_window=reader.integer("FinePositionErrorWindow"), + gain=reader.integer("Gain"), + encoder_to_motor_tick_ratio=reader.floating("EncoderToMotorTickRatio"), + backlash_compensation=reader.integer("BacklashCompensation"), + motor_response_time=reader.integer("MotorResponseTime"), + ) + + +@dataclass +class AxisConfig: + """Configuration shared by encoder-controlled motors.""" + + motion_name: str + config_version: int + motor_type: int + comm_index: int + controller_index: int + axis_index: int + enabled: bool + + # velocity / acceleration profile + max_velocity: float + max_acceleration: float + max_deceleration: float + max_s_acceleration: int + moderate_acceleration: float + minimum_acceleration: float + moderate_s_acceleration: int + minimum_s_acceleration: int + s_curve_support: bool + + # homing + home_type: str + homing_velocity: float + index_velocity: float + homing_short_move: int + home_offset: float + + # limits / direction + positive_limit: bool + negative_limit: bool + limit_polarity: int + invert_axis_direction: bool + default_positive_direction: bool + + # motor currents (percent) + moving_current_percentage: int + holding_current_percentage: int + loading_current_percentage: int + moving_overload_limit: int + + # closed-loop / encoder / position correction + mode_enable_limits: bool + mode_enable_step_and_direction: bool + mode_enable_position_correction: bool + mode_enable_motor_slave_to_encoder: bool + coarse_position_error_window: int + fine_position_error_window: int + gain: int + encoder_to_motor_tick_ratio: float + backlash_compensation: int + motor_response_time: int + + unrecognized_fields: Dict[str, str] = field(default_factory=dict, init=False) + + @classmethod + def from_element(cls, element: ET.Element) -> "AxisConfig": + reader = _XmlScalars(_leaf_scalars(element)) + values = _read_axis_values(reader, reader.integer("LimitPolarity")) + config = cls( + motion_name=values.motion_name, + config_version=values.config_version, + motor_type=values.motor_type, + comm_index=values.comm_index, + controller_index=values.controller_index, + axis_index=values.axis_index, + enabled=values.enabled, + max_velocity=values.max_velocity, + max_acceleration=values.max_acceleration, + max_deceleration=values.max_deceleration, + max_s_acceleration=values.max_s_acceleration, + moderate_acceleration=values.moderate_acceleration, + minimum_acceleration=values.minimum_acceleration, + moderate_s_acceleration=values.moderate_s_acceleration, + minimum_s_acceleration=values.minimum_s_acceleration, + s_curve_support=values.s_curve_support, + home_type=values.home_type, + homing_velocity=values.homing_velocity, + index_velocity=values.index_velocity, + homing_short_move=values.homing_short_move, + home_offset=values.home_offset, + positive_limit=values.positive_limit, + negative_limit=values.negative_limit, + limit_polarity=values.limit_polarity, + invert_axis_direction=values.invert_axis_direction, + default_positive_direction=values.default_positive_direction, + moving_current_percentage=values.moving_current_percentage, + holding_current_percentage=values.holding_current_percentage, + loading_current_percentage=values.loading_current_percentage, + moving_overload_limit=values.moving_overload_limit, + mode_enable_limits=values.mode_enable_limits, + mode_enable_step_and_direction=values.mode_enable_step_and_direction, + mode_enable_position_correction=values.mode_enable_position_correction, + mode_enable_motor_slave_to_encoder=values.mode_enable_motor_slave_to_encoder, + coarse_position_error_window=values.coarse_position_error_window, + fine_position_error_window=values.fine_position_error_window, + gain=values.gain, + encoder_to_motor_tick_ratio=values.encoder_to_motor_tick_ratio, + backlash_compensation=values.backlash_compensation, + motor_response_time=values.motor_response_time, + ) + config.unrecognized_fields = reader.unrecognized() + return config + + +@dataclass +class LinearAxisConfig(AxisConfig): + """A linear X, Y, or Z motor with millimeter position bounds.""" + + min_position: float + max_position: float + mm_per_encoder_tick: float + + @classmethod + def from_element(cls, element: ET.Element) -> "LinearAxisConfig": + reader = _XmlScalars(_leaf_scalars(element)) + # Linear-axis vendor files commonly omit LimitPolarity; their fixed polarity is 0. + values = _read_axis_values(reader, reader.integer_or("LimitPolarity", 0)) + config = cls( + motion_name=values.motion_name, + config_version=values.config_version, + motor_type=values.motor_type, + comm_index=values.comm_index, + controller_index=values.controller_index, + axis_index=values.axis_index, + enabled=values.enabled, + max_velocity=values.max_velocity, + max_acceleration=values.max_acceleration, + max_deceleration=values.max_deceleration, + max_s_acceleration=values.max_s_acceleration, + moderate_acceleration=values.moderate_acceleration, + minimum_acceleration=values.minimum_acceleration, + moderate_s_acceleration=values.moderate_s_acceleration, + minimum_s_acceleration=values.minimum_s_acceleration, + s_curve_support=values.s_curve_support, + home_type=values.home_type, + homing_velocity=values.homing_velocity, + index_velocity=values.index_velocity, + homing_short_move=values.homing_short_move, + home_offset=values.home_offset, + positive_limit=values.positive_limit, + negative_limit=values.negative_limit, + limit_polarity=values.limit_polarity, + invert_axis_direction=values.invert_axis_direction, + default_positive_direction=values.default_positive_direction, + moving_current_percentage=values.moving_current_percentage, + holding_current_percentage=values.holding_current_percentage, + loading_current_percentage=values.loading_current_percentage, + moving_overload_limit=values.moving_overload_limit, + mode_enable_limits=values.mode_enable_limits, + mode_enable_step_and_direction=values.mode_enable_step_and_direction, + mode_enable_position_correction=values.mode_enable_position_correction, + mode_enable_motor_slave_to_encoder=values.mode_enable_motor_slave_to_encoder, + coarse_position_error_window=values.coarse_position_error_window, + fine_position_error_window=values.fine_position_error_window, + gain=values.gain, + encoder_to_motor_tick_ratio=values.encoder_to_motor_tick_ratio, + backlash_compensation=values.backlash_compensation, + motor_response_time=values.motor_response_time, + min_position=reader.floating("MinPosition"), + max_position=reader.floating("MaxPosition"), + mm_per_encoder_tick=reader.floating("MMPerEncoderTick"), + ) + config.unrecognized_fields = reader.unrecognized() + return config + + +@dataclass +class GalvoConfig: + """A galvanometer scan axis (``XGalvo`` / ``YGalvo``). + + Note: galvo sections carry no ``MotionName``; they are voltage-driven DAC axes. + """ + + config_version: int + controller_index: int + position_error_window: int + velocity_error_window: int + big_move_delay: float + min_voltage: float + max_voltage: float + invert_voltage: bool + enabled: bool + unrecognized_fields: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_element(cls, element: ET.Element) -> "GalvoConfig": + reader = _XmlScalars(_leaf_scalars(element)) + return cls( + config_version=reader.integer("ConfigVersion"), + controller_index=reader.integer("ControllerIndex"), + position_error_window=reader.integer("PositionErrorWindow"), + velocity_error_window=reader.integer("VelocityErrorWindow"), + big_move_delay=reader.integer("BigMoveDelayMS") / 1000.0, + min_voltage=reader.floating("MinVoltage"), + max_voltage=reader.floating("MaxVoltage"), + invert_voltage=reader.boolean("InvertVoltage"), + enabled=reader.boolean("Enabled"), + unrecognized_fields=reader.unrecognized(), + ) + + +@dataclass(frozen=True) +class GalvoMagnificationCalibration: + """Imaging center and frame span for one objective magnification.""" + + center_voltage: float + frame_size_volts: float + + +@dataclass(frozen=True) +class GalvoAxisOpticalCalibration: + """Optical-center calibration for one galvo axis from LEAP calibration XML.""" + + magnifications: Dict[int, GalvoMagnificationCalibration] + logical_filter_offsets: Dict[int, float] + laser_center_voltage: float + uv_laser_center_voltage: float + + +@dataclass(frozen=True) +class GalvoOpticalCalibration: + """X/Y imaging-center calibration from ``leaphardwarecalibration.config``.""" + + x: GalvoAxisOpticalCalibration + y: GalvoAxisOpticalCalibration + source_path: Optional[str] = None + + +@dataclass +class ExternalCameraControlConfig: + """Camera trigger/status-line configuration from ``ExternalCameraControl``.""" + + config_version: int + enabled: bool + invert_busy: bool + invert_integration: bool + unrecognized_fields: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_element(cls, element: ET.Element) -> "ExternalCameraControlConfig": + reader = _XmlScalars(_leaf_scalars(element)) + return cls( + config_version=reader.integer("ConfigVersion"), + enabled=reader.boolean("Enabled"), + invert_busy=reader.boolean("InvertBusy"), + invert_integration=reader.boolean("InvertIntegration"), + unrecognized_fields=reader.unrecognized(), + ) + + +@dataclass +class FilterMapEntry: + """One physical<->logical filter position mapping (``FilterMap``).""" + + logical_number: int + physical_number: int + unrecognized_fields: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_element(cls, element: ET.Element) -> "FilterMapEntry": + reader = _XmlScalars(_leaf_scalars(element)) + return cls( + logical_number=reader.integer("LogicalNumber"), + physical_number=reader.integer("PhysicalNumber"), + unrecognized_fields=reader.unrecognized(), + ) + + +@dataclass +class FilterWheelConfig(AxisConfig): + """A discrete rotary filter wheel (``DichroicFilterWheel`` and friends).""" + + encoder_ticks_per_revolution: int + number_of_filters: int + filter_map: List[FilterMapEntry] + + @classmethod + def from_element(cls, element: ET.Element) -> "FilterWheelConfig": + reader = _XmlScalars(_leaf_scalars(element)) + values = _read_axis_values(reader, reader.integer("LimitPolarity")) + config = cls( + motion_name=values.motion_name, + config_version=values.config_version, + motor_type=values.motor_type, + comm_index=values.comm_index, + controller_index=values.controller_index, + axis_index=values.axis_index, + enabled=values.enabled, + max_velocity=values.max_velocity, + max_acceleration=values.max_acceleration, + max_deceleration=values.max_deceleration, + max_s_acceleration=values.max_s_acceleration, + moderate_acceleration=values.moderate_acceleration, + minimum_acceleration=values.minimum_acceleration, + moderate_s_acceleration=values.moderate_s_acceleration, + minimum_s_acceleration=values.minimum_s_acceleration, + s_curve_support=values.s_curve_support, + home_type=values.home_type, + homing_velocity=values.homing_velocity, + index_velocity=values.index_velocity, + homing_short_move=values.homing_short_move, + home_offset=values.home_offset, + positive_limit=values.positive_limit, + negative_limit=values.negative_limit, + limit_polarity=values.limit_polarity, + invert_axis_direction=values.invert_axis_direction, + default_positive_direction=values.default_positive_direction, + moving_current_percentage=values.moving_current_percentage, + holding_current_percentage=values.holding_current_percentage, + loading_current_percentage=values.loading_current_percentage, + moving_overload_limit=values.moving_overload_limit, + mode_enable_limits=values.mode_enable_limits, + mode_enable_step_and_direction=values.mode_enable_step_and_direction, + mode_enable_position_correction=values.mode_enable_position_correction, + mode_enable_motor_slave_to_encoder=values.mode_enable_motor_slave_to_encoder, + coarse_position_error_window=values.coarse_position_error_window, + fine_position_error_window=values.fine_position_error_window, + gain=values.gain, + encoder_to_motor_tick_ratio=values.encoder_to_motor_tick_ratio, + backlash_compensation=values.backlash_compensation, + motor_response_time=values.motor_response_time, + encoder_ticks_per_revolution=reader.integer("NumberOfEncoderTickPerRev"), + number_of_filters=reader.integer("NumberOfFilters"), + filter_map=[ + FilterMapEntry.from_element(child) + for child in element + if _xml_local_name(child.tag) == "FilterMap" + ], + ) + config.unrecognized_fields = reader.unrecognized() + return config + + +@dataclass +class AnalogInputConfig: + """One analog input from ``IOConfiguration``.""" + + config_version: int + controller_index: int + channel: int + enabled: bool + invert: bool + io_name: str + unrecognized_fields: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_element(cls, element: ET.Element) -> "AnalogInputConfig": + reader = _XmlScalars(_leaf_scalars(element)) + return cls( + config_version=reader.integer("ConfigVersion"), + controller_index=reader.integer("ControllerIndex"), + channel=reader.integer("Channel"), + enabled=reader.boolean("Enabled"), + invert=reader.boolean("Invert"), + io_name=reader.text("IOName"), + unrecognized_fields=reader.unrecognized(), + ) + + +@dataclass +class DigitalIOConfig: + """One digital input or output from ``IOConfiguration``.""" + + config_version: int + io_type: str + bit_index: int + invert: bool + enabled: bool + io_name: str + unrecognized_fields: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_element(cls, element: ET.Element) -> "DigitalIOConfig": + reader = _XmlScalars(_leaf_scalars(element)) + return cls( + config_version=reader.integer("ConfigVersion"), + io_type=reader.text("IOType"), + bit_index=reader.integer("BitIndex"), + invert=reader.boolean("Invert"), + enabled=reader.boolean("Enabled"), + io_name=reader.text("IOName"), + unrecognized_fields=reader.unrecognized(), + ) + + +@dataclass +class LightingIOConfig: + """One analog lighting output from ``IOConfiguration``.""" + + config_version: int + controller_index: int + channel: int + enabled: bool + invert: bool + io_name: str + min_voltage: float + max_voltage: float + delay: float + unrecognized_fields: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_element(cls, element: ET.Element) -> "LightingIOConfig": + reader = _XmlScalars(_leaf_scalars(element)) + return cls( + config_version=reader.integer("ConfigVersion"), + controller_index=reader.integer("ControllerIndex"), + channel=reader.integer("Channel"), + enabled=reader.boolean("Enabled"), + invert=reader.boolean("Invert"), + io_name=reader.text("IOName"), + min_voltage=reader.floating("MinVoltage"), + max_voltage=reader.floating("MaxVoltage"), + delay=reader.integer("DelayMS") / 1000.0, + unrecognized_fields=reader.unrecognized(), + ) + + +@dataclass +class IOConfig: + """The board IO map: analog ins, digital IOs and lighting IOs.""" + + analog_ins: List[AnalogInputConfig] + digital_ios: List[DigitalIOConfig] + lighting_ios: List[LightingIOConfig] + + @classmethod + def from_element(cls, element: ET.Element) -> "IOConfig": + analog_inputs: List[AnalogInputConfig] = [] + digital_ios: List[DigitalIOConfig] = [] + lighting_ios: List[LightingIOConfig] = [] + for child in element: + collection_name = _xml_local_name(child.tag) + if collection_name == "AnalogIns": + analog_inputs.append(AnalogInputConfig.from_element(child)) + elif collection_name == "DigitalIOs": + digital_ios.append(DigitalIOConfig.from_element(child)) + elif collection_name == "LightingIOs": + lighting_ios.append(LightingIOConfig.from_element(child)) + return cls( + analog_ins=analog_inputs, + digital_ios=digital_ios, + lighting_ios=lighting_ios, + ) + + +@dataclass +class CeligoHardwareConfig: + """Root hardware config, parsed from ``USBIOHardwareConfig.config``. + + Parse an explicit file with :meth:`from_xml`, or construct it directly as one + subobject of :class:`CeligoConfig`. + """ + + x_axis: Optional[LinearAxisConfig] = None + y_axis: Optional[LinearAxisConfig] = None + z_axis: Optional[LinearAxisConfig] = None + x_galvo: Optional[GalvoConfig] = None + y_galvo: Optional[GalvoConfig] = None + external_camera_control: Optional[ExternalCameraControlConfig] = None + beam_expander: Optional[AxisConfig] = None + camera_filter_wheel: Optional[FilterWheelConfig] = None + dichroic_filter_wheel: Optional[FilterWheelConfig] = None + door: Optional[AxisConfig] = None + excitation_filter_wheel: Optional[FilterWheelConfig] = None + excitation_nd_filter_wheel: Optional[FilterWheelConfig] = None + laser_attenuator: Optional[AxisConfig] = None + laser_nd_filter_wheel: Optional[FilterWheelConfig] = None + magnification_changer: Optional[FilterWheelConfig] = None + io: Optional[IOConfig] = None + source_path: Optional[str] = None + + @staticmethod + def _inner_root(tree_root: ET.Element) -> ET.Element: + """Descend through the .NET ``xmlSerializerSection`` envelope to the config body. + + Returns the element whose children are ``XAxis``/``YAxis``/... (i.e. the serialized + ``USBIOConfigurationFile``). Tolerates the file being given with or without the + ```` / ```` wrapper. + """ + for el in tree_root.iter(): + child_tags = {_xml_local_name(c.tag) for c in el} + if "XAxis" in child_tags or "YAxis" in child_tags: + return el + return tree_root + + @classmethod + def from_xml(cls, path: str) -> "CeligoHardwareConfig": + """Parse a ``USBIOHardwareConfig.config`` file into a config object.""" + root = ET.parse(path).getroot() + body = cls._inner_root(root) + hardware_config = cls(source_path=os.path.abspath(path)) + for child in body: + name = _xml_local_name(child.tag) + if name == "XAxis": + hardware_config.x_axis = LinearAxisConfig.from_element(child) + elif name == "YAxis": + hardware_config.y_axis = LinearAxisConfig.from_element(child) + elif name == "ZSingleAxis": + hardware_config.z_axis = LinearAxisConfig.from_element(child) + elif name == "XGalvo": + hardware_config.x_galvo = GalvoConfig.from_element(child) + elif name == "YGalvo": + hardware_config.y_galvo = GalvoConfig.from_element(child) + elif name == "ExternalCameraControl": + hardware_config.external_camera_control = ExternalCameraControlConfig.from_element(child) + elif name == "BeamExpander": + hardware_config.beam_expander = AxisConfig.from_element(child) + elif name == "CameraFilterWheel": + hardware_config.camera_filter_wheel = FilterWheelConfig.from_element(child) + elif name == "DichroicFilterWheel": + hardware_config.dichroic_filter_wheel = FilterWheelConfig.from_element(child) + elif name == "Door": + hardware_config.door = AxisConfig.from_element(child) + elif name == "ExcitationFilterWheel": + hardware_config.excitation_filter_wheel = FilterWheelConfig.from_element(child) + elif name == "ExcitationNDFilterWheel": + hardware_config.excitation_nd_filter_wheel = FilterWheelConfig.from_element(child) + elif name == "LaserAttenuator": + hardware_config.laser_attenuator = AxisConfig.from_element(child) + elif name == "LaserNDFilterWheel": + hardware_config.laser_nd_filter_wheel = FilterWheelConfig.from_element(child) + elif name == "MagChanger": + hardware_config.magnification_changer = FilterWheelConfig.from_element(child) + elif name == "IOConfiguration": + hardware_config.io = IOConfig.from_element(child) + return hardware_config + + +@dataclass +class ChannelDescriptor: + """An imaging channel from ``ChannelConfig.xml`` (Default / HWAF / fluorescence).""" + + name: str + fixed_type: str + description: str + guid: str + calibration_index: int + channel_key: str + unrecognized_fields: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_element(cls, element: ET.Element) -> "ChannelDescriptor": + reader = _XmlScalars(_leaf_scalars(element)) + return cls( + name=reader.text("Name"), + fixed_type=reader.text("FixedType"), + description=reader.text("Description"), + guid=reader.text("ChannelDescGUID"), + calibration_index=reader.integer("CalibrationIndex"), + channel_key=reader.text("GetChanDescKey"), + unrecognized_fields=reader.unrecognized(), + ) + + +def load_channel_descriptors(path: str) -> List[ChannelDescriptor]: + """Parse ``ChannelConfig.xml`` into a list of :class:`ChannelDescriptor`. + + Collects channel descriptors from the XML, deduplicating by GUID and preserving order. + """ + root = ET.parse(path).getroot() + channels: List[ChannelDescriptor] = [] + seen: set = set() + for el in root.iter(): + if _xml_local_name(el.tag) == "ChannelDescriptor": + ch = ChannelDescriptor.from_element(el) + if ch.guid and ch.guid in seen: + continue + seen.add(ch.guid) + channels.append(ch) + return channels + + +@dataclass(frozen=True) +class IlluminationChannelConfig: + """Hardware recipe for one image channel from ``leaphardwarecalibration.config``.""" + + name: str + display_name: str + logical_filter: int + bit_value: Optional[int] + intensity_percent: float + lighting_io_name: str + strobe: bool + z_offset_to_brightfield_mm: float + mm_per_pixel_x_correction_to_brightfield: float + mm_per_pixel_y_correction_to_brightfield: float + + +def _magnification_voltage_tag(magnification: int) -> str: + if magnification not in (3, 5, 10, 20): + raise ValueError("magnification must be one of 3, 5, 10, or 20") + return f"VoltageMag{magnification}X" + + +def _normalize_illumination_channel_name(display_name: str) -> str: + normalized = display_name.strip().lower().replace("-", " ") + if normalized.startswith("brightfield"): + return "brightfield" + if normalized.startswith("far red"): + return "far_red" + for name in ("green", "red", "blue"): + if normalized.startswith(name): + return name + return normalized.replace(" ", "_") + + +def _load_illumination_channels( + root: ET.Element, + magnification: int, +) -> Dict[str, IlluminationChannelConfig]: + voltage_tag = _magnification_voltage_tag(magnification) + channels: Dict[str, IlluminationChannelConfig] = {} + + for element in root.iter(): + element_name = _xml_local_name(element.tag) + if element_name not in ("BFVoltageCal", "FLLight"): + continue + scalars = _all_leaf_scalars(element) + display_name = "Brightfield" if element_name == "BFVoltageCal" else scalars.get("Name", "") + name = _normalize_illumination_channel_name(display_name) + if not name: + continue + required = {"LogicalFilter", voltage_tag} + if element_name == "FLLight": + required.update({"Name", "BitValue"}) + missing = sorted(required - scalars.keys()) + if missing: + raise ValueError(f"Channel {display_name or element_name} is missing {', '.join(missing)}") + logical_filter = int(scalars["LogicalFilter"]) + intensity = float(scalars[voltage_tag]) + bit_value = None if element_name == "BFVoltageCal" else int(scalars["BitValue"]) + z_offset = float(scalars.get("CalibratedZOffsetToBFMM", "0")) + x_correction = float(scalars.get("CalibratedMMPerPixelXCorrectionToBF", "1")) + y_correction = float(scalars.get("CalibratedMMPerPixelYCorrectionToBF", "1")) + if logical_filter < 0 or not math.isfinite(intensity) or not 0 <= intensity <= 100: + raise ValueError(f"Channel {display_name} has invalid filter/intensity calibration") + if bit_value is not None and not 0 <= bit_value <= 3: + raise ValueError(f"Channel {display_name} has invalid selector BitValue {bit_value}") + if not math.isfinite(z_offset) or any( + not math.isfinite(value) or value <= 0 for value in (x_correction, y_correction) + ): + raise ValueError(f"Channel {display_name} has invalid spatial calibration") + channels[name] = IlluminationChannelConfig( + name=name, + display_name=display_name, + logical_filter=logical_filter, + bit_value=bit_value, + intensity_percent=intensity, + lighting_io_name=( + "eBrightFieldIntensity" if element_name == "BFVoltageCal" else "eFluorescentIntensity" + ), + strobe=element_name == "FLLight", + z_offset_to_brightfield_mm=z_offset, + mm_per_pixel_x_correction_to_brightfield=x_correction, + mm_per_pixel_y_correction_to_brightfield=y_correction, + ) + return channels + + +def load_illumination_channels( + path: str, magnification: int = 10 +) -> Dict[str, IlluminationChannelConfig]: + """Load one magnification's illumination recipes from LEAP calibration XML.""" + return _load_illumination_channels(ET.parse(path).getroot(), magnification) + + +def _load_all_illumination_channels( + root: ET.Element, +) -> Dict[int, Dict[str, IlluminationChannelConfig]]: + magnifications = { + int(tag[len("VoltageMag") : -1]) + for element in root.iter() + for tag in (_xml_local_name(element.tag),) + if tag.startswith("VoltageMag") and tag.endswith("X") and tag[len("VoltageMag") : -1].isdigit() + } + if not magnifications: + raise ValueError("LEAP calibration contains no magnification-specific channel voltages") + return { + magnification: _load_illumination_channels(root, magnification) + for magnification in sorted(magnifications) + } + + +def _load_galvo_axis_optical_calibration(element: ET.Element) -> GalvoAxisOpticalCalibration: + magnifications: Dict[int, GalvoMagnificationCalibration] = {} + logical_filter_offsets: Dict[int, float] = {} + laser_center_voltage: Optional[float] = None + uv_laser_center_voltage: Optional[float] = None + for child in element: + name = _xml_local_name(child.tag) + if name.startswith("ImageCenter") and name.endswith("X"): + try: + magnification = int(name[len("ImageCenter") : -1]) + except ValueError: + continue + values = _all_leaf_scalars(child) + if "CenterVoltage" not in values or "FrameSizeVolts" not in values: + raise ValueError(f"{name} is missing center/frame calibration") + magnifications[magnification] = GalvoMagnificationCalibration( + center_voltage=float(values["CenterVoltage"]), + frame_size_volts=float(values["FrameSizeVolts"]), + ) + elif name == "LogicalFilterCenterVoltageOffset": + values = _all_leaf_scalars(child) + if "LogicalNumber" in values and "CenterVoltageOffset" in values: + logical_filter_offsets[int(values["LogicalNumber"])] = float(values["CenterVoltageOffset"]) + elif name == "LaserCenterVoltage" and child.text: + laser_center_voltage = float(child.text) + elif name == "UVLaserCenterVoltage" and child.text: + uv_laser_center_voltage = float(child.text) + if laser_center_voltage is None or uv_laser_center_voltage is None: + missing_centers = [ + name + for name, value in ( + ("LaserCenterVoltage", laser_center_voltage), + ("UVLaserCenterVoltage", uv_laser_center_voltage), + ) + if value is None + ] + raise ValueError(f"{_xml_local_name(element.tag)} is missing {', '.join(missing_centers)}") + return GalvoAxisOpticalCalibration( + magnifications=magnifications, + logical_filter_offsets=logical_filter_offsets, + laser_center_voltage=laser_center_voltage, + uv_laser_center_voltage=uv_laser_center_voltage, + ) + + +def _load_galvo_optical_calibration( + root: ET.Element, + source_path: Optional[str] = None, +) -> GalvoOpticalCalibration: + axes: Dict[str, GalvoAxisOpticalCalibration] = {} + for element in root.iter(): + name = _xml_local_name(element.tag) + if name in ("XGalvo", "YGalvo"): + axes[name] = _load_galvo_axis_optical_calibration(element) + missing = [name for name in ("XGalvo", "YGalvo") if name not in axes] + if missing: + raise ValueError(f"Missing galvo optical calibration section(s): {', '.join(missing)}") + for name, axis in axes.items(): + if not axis.magnifications: + raise ValueError(f"{name} has no imaging-center calibration") + for magnification, values in axis.magnifications.items(): + if ( + not math.isfinite(values.center_voltage) + or not math.isfinite(values.frame_size_volts) + or values.frame_size_volts <= 0 + ): + raise ValueError(f"{name} {magnification}X calibration is invalid") + if any( + not math.isfinite(value) + for value in ( + *axis.logical_filter_offsets.values(), + axis.laser_center_voltage, + axis.uv_laser_center_voltage, + ) + ): + raise ValueError(f"{name} contains a non-finite center calibration") + return GalvoOpticalCalibration( + x=axes["XGalvo"], + y=axes["YGalvo"], + source_path=os.path.abspath(source_path) if source_path is not None else None, + ) + + +def load_galvo_optical_calibration(path: str) -> GalvoOpticalCalibration: + """Load galvo centers, frame spans, and filter offsets from LEAP calibration XML.""" + return _load_galvo_optical_calibration(ET.parse(path).getroot(), source_path=path) + + +@dataclass +class Calibrated2DPolynomialTransform: + """A 2D quadratic or cubic coordinate transform. + + Each named polynomial term maps to an ``(x_coeff, y_coeff)`` pair. ``forward`` and + ``reverse`` hold the two directions (galvo volts->mm and mm->galvo volts). + """ + + forward: Dict[str, "tuple[float, float]"] + reverse: Dict[str, "tuple[float, float]"] + order: int + successful: Optional[bool] = None + source_path: Optional[str] = None + + @staticmethod + def _terms(direction_element: ET.Element) -> Dict[str, "tuple[float, float]"]: + terms: Dict[str, "tuple[float, float]"] = {} + for term in direction_element: + name = _xml_local_name(term.tag) + x = y = 0.0 + for comp in term: + cn = _xml_local_name(comp.tag) + if cn == "X": + x = float(comp.text) if comp.text else 0.0 + elif cn == "Y": + y = float(comp.text) if comp.text else 0.0 + terms[name] = (x, y) + return terms + + @classmethod + def from_element(cls, element: ET.Element) -> "Calibrated2DPolynomialTransform": + type_name = _xml_local_name(element.tag).lower() + forward: Optional[Dict[str, "tuple[float, float]"]] = None + reverse: Optional[Dict[str, "tuple[float, float]"]] = None + successful: Optional[bool] = None + for el in element: + name = _xml_local_name(el.tag) + if name == "Forward": + forward = cls._terms(el) + elif name == "Reverse": + reverse = cls._terms(el) + elif name == "LastGalvoCalSuccessful" and el.text: + successful = _XmlScalars({"value": el.text}).boolean("value") + if forward is None or reverse is None: + raise ValueError("Galvo transformation requires Forward and Reverse coefficients") + return cls( + forward=forward, + reverse=reverse, + order=2 if "quadratic" in type_name else 3, + successful=successful, + ) + + @classmethod + def from_xml(cls, path: str) -> "Calibrated2DPolynomialTransform": + root = ET.parse(path).getroot() + transform = next( + ( + el + for el in root.iter() + if "transformation" in _xml_local_name(el.tag).lower() + or "tranformation" in _xml_local_name(el.tag).lower() + ), + root, + ) + obj = cls.from_element(transform) + obj.source_path = os.path.abspath(path) + return obj + + +def load_galvo_calibrations(path: str) -> Dict[int, Calibrated2DPolynomialTransform]: + """Load every per-logical-filter galvo transform in a calibration file.""" + root = ET.parse(path).getroot() + calibrations: Dict[int, Calibrated2DPolynomialTransform] = {} + for setting in root.iter(): + if _xml_local_name(setting.tag) != "setting": + continue + key = setting.attrib.get("key", "") + prefix = "GalvoCalibrationConfig_" + if not key.startswith(prefix): + continue + try: + logical_filter = int(key[len(prefix) :]) + except ValueError: + continue + transform_el = next(iter(setting), None) + if transform_el is None: + continue + transform = Calibrated2DPolynomialTransform.from_element(transform_el) + transform.source_path = os.path.abspath(path) + calibrations[logical_filter] = transform + return calibrations + + +@dataclass +class CalibrationConfig: + """Per-machine optical/stage calibration (``CalibrationConfig.xml``). + + Feeds the pixel<->mm and sample-mm<->stage-mm affine transforms (see + :mod:`pylabrobot.revvity.celigo.coordinates`). + """ + + microns_per_pixel_x: float + microns_per_pixel_y: float + image_width_pixels: int + image_height_pixels: int + image_to_stage_theta_radians: float + galvo_to_stage_theta_radians: float + calibrated_plate_corner_x: float + calibrated_plate_corner_y: float + calibrated_plate_to_stage_theta_radians: float + stage_x_scale: float + stage_y_scale: float + stage_shear: float + stage_x_shear_offset: float + stage_y_shear_offset: float + calibrated_z_position: float + calibrated_z_glass_plate_delta: float + z_plane_x_coeff: float + z_plane_y_coeff: float + source_path: Optional[str] = None + unrecognized_fields: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_xml(cls, path: str) -> "CalibrationConfig": + reader = _XmlScalars(_all_leaf_scalars(ET.parse(path).getroot())) + return cls( + microns_per_pixel_x=reader.floating("MicronsPerPixelX"), + microns_per_pixel_y=reader.floating("MicronsPerPixelY"), + image_width_pixels=reader.integer("ImageWidthPixels"), + image_height_pixels=reader.integer("ImageHeightPixels"), + image_to_stage_theta_radians=reader.floating("ImageToStageThetaRadians"), + galvo_to_stage_theta_radians=reader.floating("GalvoToStageThetaRadians"), + calibrated_plate_corner_x=reader.floating("CalibratedPlateCornerX"), + calibrated_plate_corner_y=reader.floating("CalibratedPlateCornerY"), + calibrated_plate_to_stage_theta_radians=reader.floating("CalibratedPlateToStageThetaRadians"), + stage_x_scale=reader.floating("StageXScale"), + stage_y_scale=reader.floating("StageYScale"), + stage_shear=reader.floating("StageShear"), + stage_x_shear_offset=reader.floating("StageXShearOffset"), + stage_y_shear_offset=reader.floating("StageYShearOffset"), + calibrated_z_position=reader.floating("CalibratedZPosition"), + calibrated_z_glass_plate_delta=reader.floating("CalibratedZGlassPlateDelta"), + z_plane_x_coeff=reader.floating("ZPlaneXCoeff"), + z_plane_y_coeff=reader.floating("ZPlaneYCoeff"), + source_path=os.path.abspath(path), + unrecognized_fields=reader.unrecognized(), + ) + + +@dataclass +class HardwareDefaultConfig: + """Instrument defaults (``HardwareDefaultConfig.xml``): plate corner, FOV, galvo MM/V.""" + + default_calibrated_z: float + default_plate_x_corner_stage_coordinate: float + default_plate_y_corner_stage_coordinate: float + default_x_field_of_view_mm: float + default_y_field_of_view_mm: float + default_x_galvo_mm_per_volt: float + default_y_galvo_mm_per_volt: float + source_path: Optional[str] = None + unrecognized_fields: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_xml(cls, path: str) -> "HardwareDefaultConfig": + reader = _XmlScalars(_all_leaf_scalars(ET.parse(path).getroot())) + return cls( + default_calibrated_z=reader.floating("DefaultCalibratedZ"), + default_plate_x_corner_stage_coordinate=reader.floating("DefaultPlateXCornerStageCoordinate"), + default_plate_y_corner_stage_coordinate=reader.floating("DefaultPlateYCornerStageCoordinate"), + default_x_field_of_view_mm=reader.floating("DefaultXFieldOfViewMM"), + default_y_field_of_view_mm=reader.floating("DefaultYFieldOfViewMM"), + default_x_galvo_mm_per_volt=reader.floating("DefaultXGalvoMMPerVolt"), + default_y_galvo_mm_per_volt=reader.floating("DefaultYGalvoMMPerVolt"), + source_path=os.path.abspath(path), + unrecognized_fields=reader.unrecognized(), + ) + + +@dataclass +class NavigationConfig: + """Galvo reach and frame overlap from ``NavigationConfig.xml``.""" + + frame_overlap_x_mm: float + frame_overlap_y_mm: float + max_galvo_deflection_x_mm: float + max_galvo_deflection_y_mm: float + source_path: Optional[str] = None + unrecognized_fields: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_xml(cls, path: str) -> "NavigationConfig": + reader = _XmlScalars(_all_leaf_scalars(ET.parse(path).getroot())) + return cls( + frame_overlap_x_mm=reader.floating("FrameOverlapXMM"), + frame_overlap_y_mm=reader.floating("FrameOverlapYMM"), + max_galvo_deflection_x_mm=reader.floating("MaxGalvoDeflectionXMM"), + max_galvo_deflection_y_mm=reader.floating("MaxGalvoDeflectionYMM"), + source_path=os.path.abspath(path), + unrecognized_fields=reader.unrecognized(), + ) + + +@dataclass +class CeligoConfig: + """All parsed configuration for one Celigo instrument. + + :meth:`from_install` locates the hardware file once, indexes its directory once, and + loads every required companion configuration into explicit subobjects. Illumination + recipes for every magnification present in the vendor file are loaded up front. + """ + + hardware: CeligoHardwareConfig + channel_descriptors: List[ChannelDescriptor] + channels_by_magnification: Dict[int, Dict[str, IlluminationChannelConfig]] + calibration: CalibrationConfig + hardware_defaults: HardwareDefaultConfig + galvo_calibrations: Dict[int, Calibrated2DPolynomialTransform] + galvo_optical_calibration: GalvoOpticalCalibration + navigation: NavigationConfig + magnification: int = 3 + + def __post_init__(self) -> None: + if self.magnification not in (3, 5, 10, 20): + raise ValueError("magnification must be one of 3, 5, 10, or 20") + if self.magnification not in self.channels_by_magnification: + raise ValueError(f"No illumination-channel calibration is loaded for {self.magnification}X") + + @property + def channels(self) -> Dict[str, IlluminationChannelConfig]: + """Illumination recipes for the active magnification.""" + try: + return self.channels_by_magnification[self.magnification] + except KeyError as exc: + raise ValueError( + f"No illumination-channel calibration is loaded for {self.magnification}X" + ) from exc + + @classmethod + def from_install( + cls, + install_dir: str, + magnification: int = 3, + ) -> "CeligoConfig": + """Load the complete configuration set used to initialize :class:`Celigo`. + + ``install_dir`` may be the Celigo installation root, its ``ConfigFiles`` directory, + or the path to ``USBIOHardwareConfig.config``. + + .. code-block:: python + + config = CeligoConfig.from_install("/path/to/Celigo/ConfigFiles") + celigo = Celigo(config=config) + """ + if magnification not in (3, 5, 10, 20): + raise ValueError("magnification must be one of 3, 5, 10, or 20") + hardware_path = _locate_hardware_config_file(install_dir) + if hardware_path is None: + raise FileNotFoundError( + f"Could not find {_HARDWARE_CONFIG_FILENAME}. Pass install_dir= pointing " + "at the Celigo install root or its ConfigFiles directory." + ) + config_directory = os.path.dirname(os.path.abspath(hardware_path)) + files_by_name = { + filename.lower(): os.path.join(config_directory, filename) + for filename in os.listdir(config_directory) + if os.path.isfile(os.path.join(config_directory, filename)) + } + + def companion_file(filename: str) -> str: + path = files_by_name.get(filename.lower()) + if path is None: + raise FileNotFoundError( + f"Required Celigo configuration file {filename} is missing from {config_directory}" + ) + return path + + illumination_calibration_path = companion_file("leaphardwarecalibration.config") + channel_config_path = companion_file("ChannelConfig.xml") + calibration_path = companion_file("CalibrationConfig.xml") + hardware_defaults_path = companion_file("HardwareDefaultConfig.xml") + galvo_calibration_path = companion_file("GalvoCalibrationConfig.xml") + navigation_path = companion_file("NavigationConfig.xml") + illumination_root = ET.parse(illumination_calibration_path).getroot() + + return cls( + hardware=CeligoHardwareConfig.from_xml(hardware_path), + channel_descriptors=load_channel_descriptors(channel_config_path), + channels_by_magnification=_load_all_illumination_channels(illumination_root), + calibration=CalibrationConfig.from_xml(calibration_path), + hardware_defaults=HardwareDefaultConfig.from_xml(hardware_defaults_path), + galvo_calibrations=load_galvo_calibrations(galvo_calibration_path), + galvo_optical_calibration=_load_galvo_optical_calibration( + illumination_root, + source_path=illumination_calibration_path, + ), + navigation=NavigationConfig.from_xml(navigation_path), + magnification=magnification, + ) diff --git a/pylabrobot/revvity/celigo/coordinates.py b/pylabrobot/revvity/celigo/coordinates.py new file mode 100644 index 00000000000..bd28909847c --- /dev/null +++ b/pylabrobot/revvity/celigo/coordinates.py @@ -0,0 +1,272 @@ +"""Affine coordinate frames for the Celigo (pixel <-> sample-mm <-> stage-mm). + +Two coordinate frames are constructed: + +* ``sample_to_stage``: plate (sample) mm -> stage mm. Applies scale (``stage_x_scale``, + ``stage_y_scale``), shear offset (``stage_x_shear_offset``, ``stage_y_shear_offset``), + X-shear (``stage_shear``), and rotation (``calibrated_plate_to_stage_theta_radians``). + Reference point is the plate corner in stage coordinates. + +* ``image_to_stage``: image pixels -> stage mm. Applies pixel scale + (``microns_per_pixel_x`` / ``microns_per_pixel_y``) about the image center, rotation + (``image_to_stage_theta_radians``), and chains through ``sample_to_stage``. Reference + point is the current FOR+FOV center in sample mm. + +Methods provide conversions between coordinate spaces: sample mm <-> stage mm, image +pixels <-> sample mm, image pixels <-> stage mm, etc. +""" + +from __future__ import annotations + +import math +from typing import Optional, Tuple + +from pylabrobot.revvity.celigo.config import CalibrationConfig, HardwareDefaultConfig + +Coordinate2D = Tuple[float, float] +Matrix2x2 = Tuple[float, float, float, float] + + +def sample_offset_mm_to_galvo_offset_mm(x: float, y: float) -> Coordinate2D: + """Convert a sample-relative FOV offset to the Celigo galvo calibration frame. + + Sample X increases from left to right. The Celigo's calibrated X-galvo frame increases + in the opposite physical direction; Y has the same orientation in both frames. + """ + if not math.isfinite(x) or not math.isfinite(y): + raise ValueError("sample offset coordinates must be finite") + return (-x, y) + + +class _CoordinateFrame: + """A rotated frame relative to an optional base frame.""" + + def __init__( + self, + reference_point: Coordinate2D, + rotation_radians: float, + parent: Optional["_CoordinateFrame"], + ): + self.parent = parent + # The reference point is expressed in parent units. Remove the parent's scale before + # chaining coordinate transforms. + unscaled_reference = ( + parent.to_unscaled_coordinates(reference_point) if parent is not None else reference_point + ) + self.reference_point = unscaled_reference + + if rotation_radians == 0.0: + self._rotation_from_parent: Matrix2x2 = (1.0, 0.0, 0.0, 1.0) + self._rotation_to_parent: Matrix2x2 = (1.0, 0.0, 0.0, 1.0) + else: + cosine = math.cos(rotation_radians) + sine = math.sin(rotation_radians) + self._rotation_from_parent = (cosine, sine, -sine, cosine) + self._rotation_to_parent = (cosine, -sine, sine, cosine) + + if parent is None: + self._cumulative_root_offset = unscaled_reference + self._cumulative_rotation_to_root = self._rotation_to_parent + else: + self._cumulative_root_offset = _add( + parent._cumulative_root_offset, + _transform_coordinate( + parent._cumulative_rotation_to_root, + unscaled_reference, + ), + ) + self._cumulative_rotation_to_root = _multiply_matrices( + self._rotation_to_parent, + parent._cumulative_rotation_to_root, + ) + + # subclasses override these (identity here) + def to_unscaled_coordinates(self, coordinate: Coordinate2D) -> Coordinate2D: + return coordinate + + def from_unscaled_coordinates(self, coordinate: Coordinate2D) -> Coordinate2D: + return coordinate + + def to_parent_coordinates(self, local: Coordinate2D) -> Coordinate2D: + unscaled_local = self.to_unscaled_coordinates(local) + parent_coordinate = _add( + _transform_coordinate(self._rotation_to_parent, unscaled_local), + self.reference_point, + ) + if self.parent is not None: + parent_coordinate = self.parent.from_unscaled_coordinates(parent_coordinate) + return parent_coordinate + + def from_parent_coordinates(self, parent_coordinate: Coordinate2D) -> Coordinate2D: + unscaled_parent = ( + self.parent.to_unscaled_coordinates(parent_coordinate) + if self.parent is not None + else parent_coordinate + ) + local = _transform_coordinate( + self._rotation_from_parent, + _subtract(unscaled_parent, self.reference_point), + ) + return self.from_unscaled_coordinates(local) + + def to_root_coordinates(self, local: Coordinate2D) -> Coordinate2D: + unscaled_local = self.to_unscaled_coordinates(local) + return _add( + _transform_coordinate(self._cumulative_rotation_to_root, unscaled_local), + self._cumulative_root_offset, + ) + + +class _ScaledCoordinateFrame(_CoordinateFrame): + """A frame with linear scale and offset applied (e.g., pixel <-> mm).""" + + def __init__( + self, + units: Coordinate2D, + offset: Coordinate2D, + reference_point: Coordinate2D, + rotation_radians: float, + parent: Optional[_CoordinateFrame], + ): + self._units = units + self._offset = offset + super().__init__(reference_point, rotation_radians, parent) + + def from_unscaled_coordinates(self, coordinate: Coordinate2D) -> Coordinate2D: + return _add(_multiply_coordinates(coordinate, self._units), self._offset) + + def to_unscaled_coordinates(self, coordinate: Coordinate2D) -> Coordinate2D: + return _divide_coordinates(_subtract(coordinate, self._offset), self._units) + + +class _ScaledShearCoordinateFrame(_CoordinateFrame): + """A frame with scale, offset, and X-shear applied (e.g., sample <-> stage).""" + + def __init__( + self, + units: Coordinate2D, + offset: Coordinate2D, + shear: float, + reference_point: Coordinate2D, + rotation_radians: float, + parent: Optional[_CoordinateFrame], + ): + self._units = units + self._offset = offset + self._shear = shear + super().__init__(reference_point, rotation_radians, parent) + + def from_unscaled_coordinates(self, coordinate: Coordinate2D) -> Coordinate2D: + scaled = _add(_multiply_coordinates(coordinate, self._units), self._offset) + return _add(scaled, (scaled[1] * self._shear, 0.0)) + + def to_unscaled_coordinates(self, coordinate: Coordinate2D) -> Coordinate2D: + shear_offset = (coordinate[1] * self._shear, 0.0) + return _divide_coordinates( + _subtract(_subtract(coordinate, shear_offset), self._offset), + self._units, + ) + + +class CoordinateSystems: + """Affine coordinate frames and conversion methods. + + Build with :meth:`from_config`. ``reference_point_mm`` is the current field center in + sample coordinates; pass it per FOV, or leave it at the sample origin for plate-relative + conversions. + """ + + def __init__(self, sample_to_stage: _CoordinateFrame, image_to_stage: _CoordinateFrame): + self._sample_to_stage = sample_to_stage + self._image_to_stage = image_to_stage + + @classmethod + def from_config( + cls, + calibration: CalibrationConfig, + hardware_defaults: HardwareDefaultConfig, + reference_point_mm: Coordinate2D = (0.0, 0.0), + binning_divisor: float = 1.0, + ) -> "CoordinateSystems": + plate_corner = ( + calibration.calibrated_plate_corner_x + + hardware_defaults.default_plate_x_corner_stage_coordinate, + calibration.calibrated_plate_corner_y + + hardware_defaults.default_plate_y_corner_stage_coordinate, + ) + sample_to_stage = _ScaledShearCoordinateFrame( + units=(calibration.stage_x_scale, calibration.stage_y_scale), + offset=(calibration.stage_x_shear_offset, calibration.stage_y_shear_offset), + shear=calibration.stage_shear, + reference_point=plate_corner, + rotation_radians=calibration.calibrated_plate_to_stage_theta_radians, + parent=None, + ) + pixels_per_mm = ( + 1000.0 / calibration.microns_per_pixel_x / binning_divisor, + 1000.0 / calibration.microns_per_pixel_y / binning_divisor, + ) + center_pixel = ( + calibration.image_width_pixels / 2.0, + calibration.image_height_pixels / 2.0, + ) + image_to_stage = _ScaledCoordinateFrame( + units=pixels_per_mm, + offset=center_pixel, + reference_point=reference_point_mm, + rotation_radians=calibration.image_to_stage_theta_radians, + parent=sample_to_stage, + ) + return cls(sample_to_stage, image_to_stage) + + # coordinate conversion API ------------------------------------------------ + + def sample_mm_to_stage_mm(self, x: float, y: float) -> Coordinate2D: + return self._sample_to_stage.to_parent_coordinates((x, y)) + + def stage_mm_to_sample_mm(self, x: float, y: float) -> Coordinate2D: + return self._sample_to_stage.from_parent_coordinates((x, y)) + + def image_pixel_to_sample_mm(self, px: float, py: float) -> Coordinate2D: + return self._image_to_stage.to_parent_coordinates((px, py)) + + def image_pixel_to_stage_mm(self, px: float, py: float) -> Coordinate2D: + return self._image_to_stage.to_root_coordinates((px, py)) + + def sample_mm_to_image_pixel(self, x: float, y: float) -> Coordinate2D: + return self._image_to_stage.from_parent_coordinates((x, y)) + + +# -- tiny vector / 2x2-matrix helpers (matrices as (a, b, c, d) = [[a,b],[c,d]]) -- + + +def _add(left: Coordinate2D, right: Coordinate2D) -> Coordinate2D: + return (left[0] + right[0], left[1] + right[1]) + + +def _subtract(left: Coordinate2D, right: Coordinate2D) -> Coordinate2D: + return (left[0] - right[0], left[1] - right[1]) + + +def _multiply_coordinates(left: Coordinate2D, right: Coordinate2D) -> Coordinate2D: + return (left[0] * right[0], left[1] * right[1]) + + +def _divide_coordinates(numerator: Coordinate2D, denominator: Coordinate2D) -> Coordinate2D: + return (numerator[0] / denominator[0], numerator[1] / denominator[1]) + + +def _transform_coordinate(matrix: Matrix2x2, coordinate: Coordinate2D) -> Coordinate2D: + return ( + matrix[0] * coordinate[0] + matrix[1] * coordinate[1], + matrix[2] * coordinate[0] + matrix[3] * coordinate[1], + ) + + +def _multiply_matrices(left: Matrix2x2, right: Matrix2x2) -> Matrix2x2: + return ( + left[0] * right[0] + left[1] * right[2], + left[0] * right[1] + left[1] * right[3], + left[2] * right[0] + left[3] * right[2], + left[2] * right[1] + left[3] * right[3], + ) diff --git a/pylabrobot/revvity/celigo/errors.py b/pylabrobot/revvity/celigo/errors.py new file mode 100644 index 00000000000..71efedd6458 --- /dev/null +++ b/pylabrobot/revvity/celigo/errors.py @@ -0,0 +1,11 @@ +"""Exceptions shared by the Celigo controller components.""" + +from typing import Optional + + +class CeligoError(Exception): + """Raised when the Celigo rejects a command or returns a malformed response.""" + + def __init__(self, message: str, ack: Optional[int] = None) -> None: + super().__init__(message) + self.ack = ack diff --git a/pylabrobot/revvity/celigo/galvo.py b/pylabrobot/revvity/celigo/galvo.py new file mode 100644 index 00000000000..c91c1aca21c --- /dev/null +++ b/pylabrobot/revvity/celigo/galvo.py @@ -0,0 +1,406 @@ +"""Galvo mirror control for the Celigo image cytometer.""" + +import asyncio +import math +import struct +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple + +from pylabrobot.revvity.celigo.config import Calibrated2DPolynomialTransform, GalvoConfig +from pylabrobot.revvity.celigo.coordinates import sample_offset_mm_to_galvo_offset_mm +from pylabrobot.revvity.celigo.errors import CeligoError +from pylabrobot.revvity.celigo.protocol import validate_payload_length + +if TYPE_CHECKING: + from pylabrobot.revvity.celigo.celigo import Celigo + + +GalvoAxisName = Literal["x", "y"] + +# Controller-board opcodes owned by the galvo subsystem. +_CMD_MOVE_GALVO = 7 +_CMD_REQUEST_CONTROLLER_STATUS = 12 +_CMD_CALIBRATE_GALVO = 27 +_CMD_GET_GALVO_CAL_DATA = 28 +_CMD_SET_GALVO_WINDOW = 29 +_CMD_GET_GALVO_POS_DATA = 31 + +_GALVO_INDEX: dict[GalvoAxisName, int] = {"x": 0, "y": 1} +_MAX_CONTROLLER_TIMEOUT_MILLISECONDS = 0xFFFF +_DAC_ZERO_VOLTS = 32767.5 +_DAC_COUNTS_PER_VOLT = 3276.75 +_POLYNOMIAL_MONOMIALS: Dict[str, Tuple[int, int]] = { + "OffsetTerm": (0, 0), + "LinearXTerm": (1, 0), + "LinearYTerm": (0, 1), + "QuadraticXTerm": (2, 0), + "CrossTerm": (1, 1), + "QuadraticYTerm": (0, 2), + "CubicXTerm": (3, 0), + "CubicYTerm": (0, 3), + "QuadraticXLinearYTerm": (2, 1), + "QuadraticYLinearXTerm": (1, 2), +} + + +@dataclass(frozen=True) +class GalvoControllerStatus: + """Complete galvo/laser state returned by ``SEND_GALVO_INFO``.""" + + x_busy: bool + y_busy: bool + x_hardware_voltage: float + y_hardware_voltage: float + fire_table_size: int + points_loaded: int + fire_table_index: int + firing_status: int + capture_armed: bool + capture_table_size: int + + +def _timeout_seconds_to_controller_milliseconds(timeout: float) -> int: + """Convert a PLR-standard timeout in seconds to controller milliseconds.""" + if not math.isfinite(timeout) or timeout < 0: + raise ValueError("timeout must be a finite, non-negative number of seconds") + timeout_milliseconds = round(timeout * 1000) + if timeout_milliseconds > _MAX_CONTROLLER_TIMEOUT_MILLISECONDS: + raise ValueError("timeout exceeds the controller's unsigned 16-bit range") + return timeout_milliseconds + + +def volts_to_dac_count(volts: float) -> int: + """Encode a galvo voltage as an unsigned 16-bit controller DAC count.""" + if not math.isfinite(volts) or not -10.0 <= volts <= 10.0: + raise ValueError("volts must be finite and within the galvo DAC range -10..10 V") + return round(volts * _DAC_COUNTS_PER_VOLT + _DAC_ZERO_VOLTS) + + +def dac_count_to_volts(dac_count: int) -> float: + """Decode an unsigned 16-bit controller DAC count to galvo volts.""" + if not 0 <= dac_count <= 0xFFFF: + raise ValueError("dac_count must be an unsigned 16-bit integer") + return (dac_count - _DAC_ZERO_VOLTS) / _DAC_COUNTS_PER_VOLT + + +def voltage_delta_to_dac_count(voltage_delta: float) -> int: + """Encode a non-negative voltage interval as a 16-bit DAC-count interval.""" + if not math.isfinite(voltage_delta) or voltage_delta < 0: + raise ValueError("voltage_delta must be finite and non-negative") + dac_count = round(voltage_delta * _DAC_COUNTS_PER_VOLT) + if dac_count > 0xFFFF: + raise ValueError("voltage_delta exceeds the controller's unsigned 16-bit range") + return dac_count + + +def _evaluate_polynomial( + terms: Dict[str, Tuple[float, float]], + x_input: float, + y_input: float, +) -> Tuple[float, float]: + x_output = 0.0 + y_output = 0.0 + for name, (x_coefficient, y_coefficient) in terms.items(): + exponents = _POLYNOMIAL_MONOMIALS.get(name) + if exponents is None: + raise CeligoError(f"Unsupported galvo calibration polynomial term {name!r}") + if not math.isfinite(x_coefficient) or not math.isfinite(y_coefficient): + raise CeligoError(f"Galvo calibration polynomial term {name!r} is not finite") + x_exponent, y_exponent = exponents + monomial = (x_input**x_exponent) * (y_input**y_exponent) + x_output += x_coefficient * monomial + y_output += y_coefficient * monomial + return x_output, y_output + + +def _mm_to_volts( + calibration: Calibrated2DPolynomialTransform, + x_mm: float, + y_mm: float, +) -> Tuple[float, float]: + """Convert a calibrated X/Y deflection in millimeters to voltage offsets.""" + return _evaluate_polynomial(calibration.reverse, x_mm, y_mm) + + +def logical_to_hardware_voltage( + axis: GalvoAxisName, + axis_config: GalvoConfig, + logical_voltage: float, +) -> float: + minimum_voltage, maximum_voltage = sorted((axis_config.min_voltage, axis_config.max_voltage)) + if ( + not math.isfinite(logical_voltage) or not minimum_voltage <= logical_voltage <= maximum_voltage + ): + raise CeligoError( + f"{axis.upper()} galvo target {logical_voltage:.6g} V is outside configured " + f"range {minimum_voltage:.6g}..{maximum_voltage:.6g} V" + ) + return -logical_voltage if axis_config.invert_voltage else logical_voltage + + +class Galvo: + """Galvo positioning, calibration, and status operations owned by a Celigo.""" + + def __init__(self, celigo: "Celigo") -> None: + self._celigo = celigo + + async def _initialize(self) -> None: + """Configure, calibrate, and center both enabled galvos.""" + hardware = self._celigo.config.hardware + axis_configs: Tuple[ + Tuple[GalvoAxisName, Optional[GalvoConfig]], + Tuple[GalvoAxisName, Optional[GalvoConfig]], + ] = (("x", hardware.x_galvo), ("y", hardware.y_galvo)) + configured: Dict[GalvoAxisName, GalvoConfig] = { + axis: config for axis, config in axis_configs if config is not None and config.enabled + } + if len(configured) == 1: + raise CeligoError("X and Y galvos must either both be enabled or both be absent") + for axis, config in configured.items(): + await self._set_settling_window( + axis, + config.position_error_window, + config.velocity_error_window, + ) + for axis in configured: + for _ in range(2): + if not await self.calibrate(axis, timeout=0.9): + raise CeligoError(f"{axis.upper()} galvo calibration failed") + if configured: + await self.home(magnification=self._celigo.config.magnification) + + def _calibration(self, logical_filter: int) -> Calibrated2DPolynomialTransform: + try: + return self._celigo.config.galvo_calibrations[logical_filter] + except KeyError as exc: + raise CeligoError( + f"No galvo calibration is configured for logical filter {logical_filter}" + ) from exc + + def _axis_config(self, axis: GalvoAxisName) -> GalvoConfig: + hardware = self._celigo.config.hardware + axis_config = hardware.x_galvo if axis == "x" else hardware.y_galvo + if axis_config is None or not axis_config.enabled: + raise CeligoError(f"{axis.upper()} galvo is not configured") + return axis_config + + def _center_voltage( + self, + axis: GalvoAxisName, + magnification: int, + logical_filter: Optional[int], + ) -> float: + optical_calibration = self._celigo.config.galvo_optical_calibration + axis_calibration = optical_calibration.x if axis == "x" else optical_calibration.y + try: + center_voltage = axis_calibration.magnifications[magnification].center_voltage + except KeyError as exc: + raise CeligoError( + f"No {axis.upper()}-galvo center is calibrated for {magnification}X" + ) from exc + if logical_filter is not None: + center_voltage += axis_calibration.logical_filter_offsets.get(logical_filter, 0.0) + return center_voltage + + def voltages_for_offset( + self, + logical_filter: int, + offset_mm: Tuple[float, float] = (0.0, 0.0), + ) -> Tuple[float, float]: + """Return galvo targets for an X-right/Y-down sample-relative field offset.""" + delta_x = delta_y = 0.0 + if logical_filter in self._celigo.config.galvo_calibrations: + galvo_offset_mm = sample_offset_mm_to_galvo_offset_mm(*offset_mm) + delta_x, delta_y = _mm_to_volts(self._calibration(logical_filter), *galvo_offset_mm) + elif offset_mm != (0.0, 0.0): + raise CeligoError(f"No galvo calibration is configured for logical filter {logical_filter}") + magnification = self._celigo.config.magnification + return ( + self._center_voltage("x", magnification, logical_filter) + delta_x, + self._center_voltage("y", magnification, logical_filter) + delta_y, + ) + + async def move_single( + self, + axis: GalvoAxisName, + logical_voltage: float, + wait_until_settled: bool = True, + timeout: float = 6.0, + ) -> float: + """Move one galvo to a logical voltage and return its hardware voltage. + + ``logical_voltage`` is in volts and ``timeout`` is in seconds. + """ + timeout_milliseconds = _timeout_seconds_to_controller_milliseconds(timeout) + axis_config = self._axis_config(axis) + if not math.isfinite(axis_config.big_move_delay) or axis_config.big_move_delay < 0: + raise CeligoError(f"{axis.upper()} galvo has an invalid configured post-move delay") + hardware_voltage = logical_to_hardware_voltage( + axis, + axis_config, + logical_voltage, + ) + payload = struct.pack( + ">HiHH", + _GALVO_INDEX[axis], + volts_to_dac_count(hardware_voltage), + 1 if wait_until_settled else 0, + timeout_milliseconds if wait_until_settled else 0, + ) + if wait_until_settled: + # The board replies after the galvo settles or its own timeout expires. Keep + # the host deadline beyond the advertised board timeout. + host_timeout = max(self._celigo.reply_timeout, timeout + 1.0) + response = await self._celigo.send_command( + _CMD_MOVE_GALVO, + payload, + reply_timeout=host_timeout, + ) + else: + response = await self._celigo.send_command(_CMD_MOVE_GALVO, payload) + if wait_until_settled: + validate_payload_length(response, 2, "galvo move") + if struct.unpack_from(">H", response, 0)[0] != 0: + raise CeligoError(f"{axis.upper()} galvo did not settle") + if axis_config.big_move_delay: + await asyncio.sleep(axis_config.big_move_delay) + return hardware_voltage + + async def move_both( + self, + x_logical_voltage: float, + y_logical_voltage: float, + wait_until_settled: bool = True, + timeout: float = 6.0, + ) -> Tuple[float, float]: + """Start both galvo moves, optionally wait for both, and return hardware voltages.""" + post_move_delay = max( + self._axis_config("x").big_move_delay, + self._axis_config("y").big_move_delay, + ) + if not math.isfinite(post_move_delay) or post_move_delay < 0: + raise CeligoError("Galvo configuration has an invalid post-move delay") + x_hardware_voltage = await self.move_single( + "x", + x_logical_voltage, + wait_until_settled=False, + timeout=timeout, + ) + y_hardware_voltage = await self.move_single( + "y", + y_logical_voltage, + wait_until_settled=False, + timeout=timeout, + ) + if wait_until_settled: + deadline = time.monotonic() + timeout + while True: + status = await self.request_controller_status() + if not status.x_busy and not status.y_busy: + break + if time.monotonic() >= deadline: + raise TimeoutError(f"Galvos did not settle within {timeout:g} seconds") + await asyncio.sleep(0.005) + if post_move_delay: + await asyncio.sleep(post_move_delay) + return x_hardware_voltage, y_hardware_voltage + + async def home( + self, + magnification: Optional[int] = None, + logical_filter: Optional[int] = None, + ) -> Tuple[float, float]: + """Move both galvos to their calibrated imaging center.""" + selected_magnification = ( + self._celigo.config.magnification if magnification is None else magnification + ) + x_center = self._center_voltage("x", selected_magnification, logical_filter) + y_center = self._center_voltage("y", selected_magnification, logical_filter) + return await self.move_both(x_center, y_center) + + async def request_controller_status(self) -> GalvoControllerStatus: + """Read the complete galvo and laser-firing state from the controller.""" + response = await self._celigo.send_command(_CMD_REQUEST_CONTROLLER_STATUS) + validate_payload_length(response, 23, "galvo controller status") + x_ready, y_ready, x_dac_count, y_dac_count = struct.unpack_from(">BBHH", response, 0) + fire_table_size, points_loaded, fire_table_index = struct.unpack_from(">iii", response, 6) + firing_status = response[18] + capture_armed, capture_table_size = struct.unpack_from(">hh", response, 19) + return GalvoControllerStatus( + x_busy=x_ready == 0, + y_busy=y_ready == 0, + x_hardware_voltage=dac_count_to_volts(x_dac_count), + y_hardware_voltage=dac_count_to_volts(y_dac_count), + fire_table_size=fire_table_size, + points_loaded=points_loaded, + fire_table_index=fire_table_index, + firing_status=firing_status, + capture_armed=capture_armed != 0, + capture_table_size=capture_table_size, + ) + + async def _set_settling_window( + self, + axis: GalvoAxisName, + position_error_count: int, + velocity_error_count: int, + ) -> None: + """Set one galvo's position and velocity settling tolerances.""" + payload = struct.pack( + ">HHH", + _GALVO_INDEX[axis], + position_error_count, + velocity_error_count, + ) + await self._celigo.send_command(_CMD_SET_GALVO_WINDOW, payload) + + async def calibrate( + self, + axis: GalvoAxisName, + timeout: float = 0.9, + ) -> bool: + """Run a galvo error-signal characterization sweep. + + ``timeout`` is in seconds. The return value reports whether the sweep succeeded. + """ + timeout_milliseconds = _timeout_seconds_to_controller_milliseconds(timeout) + payload = struct.pack( + ">HHH", + _GALVO_INDEX[axis], + timeout_milliseconds, + 1, + ) + response = await self._celigo.send_command(_CMD_CALIBRATE_GALVO, payload) + validate_payload_length(response, 2, "galvo calibration") + calibration_status = int(struct.unpack_from(">H", response, 0)[0]) + return calibration_status == 0 + + async def request_calibration_errors( + self, + axis: GalvoAxisName, + ) -> List[Tuple[int, int]]: + """Read one galvo's calibration error-count pairs.""" + response = await self._celigo.send_command( + _CMD_GET_GALVO_CAL_DATA, + struct.pack(">H", _GALVO_INDEX[axis]), + ) + validate_payload_length(response, 2, "galvo calibration data") + (item_count,) = struct.unpack_from(">h", response, 0) + if item_count < 0: + raise CeligoError(f"Invalid galvo calibration item count: {item_count}") + validate_payload_length(response, 2 + 4 * item_count, "galvo calibration data") + return [ + struct.unpack_from(">hh", response, 2 + 4 * item_index) for item_index in range(item_count) + ] + + async def request_position_trace_dac_counts(self) -> List[Tuple[int, int]]: + """Read captured galvo position/move pairs in controller DAC counts.""" + response = await self._celigo.send_command(_CMD_GET_GALVO_POS_DATA) + validate_payload_length(response, 2, "galvo position data") + (position_count,) = struct.unpack_from(">H", response, 0) + validate_payload_length(response, 2 + 4 * position_count, "galvo position data") + return [ + struct.unpack_from(">HH", response, 2 + 4 * position_index) + for position_index in range(position_count) + ] diff --git a/pylabrobot/revvity/celigo/laser.py b/pylabrobot/revvity/celigo/laser.py new file mode 100644 index 00000000000..ed8ca69387f --- /dev/null +++ b/pylabrobot/revvity/celigo/laser.py @@ -0,0 +1,350 @@ +"""Laser subsystem for the Celigo image cytometer.""" + +import contextlib +import math +import struct +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Tuple + +from pylabrobot.revvity.celigo.config import GalvoConfig +from pylabrobot.revvity.celigo.errors import CeligoError +from pylabrobot.revvity.celigo.galvo import ( + GalvoAxisName, + logical_to_hardware_voltage, + voltage_delta_to_dac_count, + volts_to_dac_count, +) +from pylabrobot.revvity.celigo.protocol import complete_cleanup, validate_payload_length + +if TYPE_CHECKING: + from pylabrobot.revvity.celigo.celigo import Celigo + from pylabrobot.revvity.celigo.motion import Axis, FilterWheel + +# Controller-board opcodes used only by the laser subsystem. +_CMD_LOAD_FIRING_TABLE = 1 +_CMD_FIRE_GALVO_GRID = 6 +_CMD_TARGETED_FIRE = 13 +_CMD_FIRE_LASER = 24 +_CMD_SEND_LASER_COMM = 26 +_CMD_READ_LASER_COMM = 32 +_DELAY_TICK_SECONDS = 10e-6 +_MAX_DELAY_TICKS = 0x7FFFFFFF + + +@dataclass(frozen=True) +class _GridDacCounts: + x_spacing: int + y_spacing: int + x_size: int + y_size: int + x_center: int + y_center: int + + +def _delay_seconds_to_controller_ticks(delay: float) -> int: + """Convert a PLR-standard delay in seconds to the controller's 10 µs ticks.""" + if not math.isfinite(delay) or delay < 0: + raise ValueError("delay must be a finite, non-negative number of seconds") + ticks = round(delay / _DELAY_TICK_SECONDS) + if ticks > _MAX_DELAY_TICKS: + raise ValueError("delay exceeds the controller's signed 32-bit range") + return ticks + + +def _encode_firing_targets( + x_config: Optional[GalvoConfig], + y_config: Optional[GalvoConfig], + voltage_offsets: List[Tuple[float, float]], + center_voltages: Tuple[float, float], +) -> List[Tuple[int, int]]: + if x_config is None or not x_config.enabled: + raise CeligoError("X galvo is not configured") + if y_config is None or not y_config.enabled: + raise CeligoError("Y galvo is not configured") + targets = [] + for x_offset, y_offset in voltage_offsets: + targets.append( + ( + volts_to_dac_count( + logical_to_hardware_voltage( + "x", + x_config, + center_voltages[0] + x_offset, + ) + ), + volts_to_dac_count( + logical_to_hardware_voltage( + "y", + y_config, + center_voltages[1] + y_offset, + ) + ), + ) + ) + return targets + + +def _validate_grid_extent( + axis: GalvoAxisName, + config: GalvoConfig, + center_voltage: float, + size_voltage: float, +) -> None: + minimum_voltage, maximum_voltage = sorted((config.min_voltage, config.max_voltage)) + if ( + center_voltage - size_voltage / 2 < minimum_voltage + or center_voltage + size_voltage / 2 > maximum_voltage + ): + raise CeligoError( + f"Laser grid {axis.upper()} extent is outside {minimum_voltage}..{maximum_voltage} V" + ) + + +def _encode_grid( + x_config: Optional[GalvoConfig], + y_config: Optional[GalvoConfig], + spacing_voltages: Tuple[float, float], + size_voltages: Tuple[float, float], + center_voltages: Tuple[float, float], +) -> _GridDacCounts: + if x_config is None or not x_config.enabled: + raise CeligoError("X galvo is not configured") + if y_config is None or not y_config.enabled: + raise CeligoError("Y galvo is not configured") + if any( + voltage <= 0 or not math.isfinite(voltage) for voltage in (*spacing_voltages, *size_voltages) + ): + raise ValueError("grid spacing and size voltages must be finite and positive") + _validate_grid_extent("x", x_config, center_voltages[0], size_voltages[0]) + _validate_grid_extent("y", y_config, center_voltages[1], size_voltages[1]) + try: + x_spacing = voltage_delta_to_dac_count(spacing_voltages[0]) + y_spacing = voltage_delta_to_dac_count(spacing_voltages[1]) + x_size = voltage_delta_to_dac_count(size_voltages[0]) + y_size = voltage_delta_to_dac_count(size_voltages[1]) + except ValueError as exc: + raise CeligoError("Laser grid spacing/size exceeds controller encoding range") from exc + return _GridDacCounts( + x_spacing=x_spacing, + y_spacing=y_spacing, + x_size=x_size, + y_size=y_size, + x_center=volts_to_dac_count(logical_to_hardware_voltage("x", x_config, center_voltages[0])), + y_center=volts_to_dac_count(logical_to_hardware_voltage("y", y_config, center_voltages[1])), + ) + + +class Laser: + """Laser UART, firing, targeting, and optical controls owned by a Celigo.""" + + def __init__(self, celigo: "Celigo", enabled: bool): + self._celigo = celigo + self._enabled = enabled + + @property + def enabled(self) -> bool: + """Whether laser commands were explicitly enabled at construction.""" + return self._enabled + + @property + def nd_filter(self) -> "FilterWheel": + """The laser neutral-density filter wheel.""" + return self._celigo._require_filter_wheel("laser_nd_filter") + + @property + def attenuator(self) -> "Axis": + """The laser attenuator motor.""" + return self._celigo._require_optical_axis("laser_attenuator") + + async def _assert_safe(self) -> None: + if not self.enabled: + raise CeligoError( + "Laser commands are disabled; construct Celigo(..., allow_laser=True) only after " + "completing the instrument laser-safety procedure" + ) + status = await self._celigo.request_controller_status() + if status.has_laser_safety_fault: + raise CeligoError(f"Laser command blocked by controller safety status {status.raw_flags:#x}") + + async def send_command(self, command: str) -> None: + """Send an ASCII command to the laser UART.""" + await self._assert_safe() + await self._celigo.send_command(_CMD_SEND_LASER_COMM, command.encode("ascii") + b"\x00") + + async def request_uart_response(self) -> str: + """Read an ASCII response from the laser UART.""" + await self._assert_safe() + response = await self._celigo.send_command(_CMD_READ_LASER_COMM) + validate_payload_length(response, 4, "laser response") + response_length = struct.unpack_from(">H", response, 2)[0] + validate_payload_length(response, 4 + response_length, "laser response") + return response[4 : 4 + response_length].rstrip(b"\x00").decode("ascii", errors="replace") + + async def fire( + self, + laser_index: int, + shots: int, + delay: float = 0.0, + ) -> None: + """Fire one laser without galvo targeting. + + ``delay`` is the interval between shots in seconds. The controller encodes it in + 10 µs ticks. + """ + if laser_index not in (0, 1): + raise ValueError("laser_index must be 0 (LASER_1) or 1 (LASER_2)") + if not 0 < shots <= 0x7FFFFFFF: + raise ValueError("shots must fit in a positive signed 32-bit integer") + delay_ticks = _delay_seconds_to_controller_ticks(delay) + await self._assert_safe() + try: + await self._celigo.send_command( + _CMD_FIRE_LASER, + struct.pack(">Hii", laser_index, shots, delay_ticks), + ) + timeout = max( + 5.0, + self._celigo.move_timeout, + max(0, shots - 1) * delay + 5.0, + ) + if not await self._celigo.wait_for_controller_ready(timeout=timeout): + raise TimeoutError("Laser firing did not complete") + except BaseException: + with contextlib.suppress(Exception): + await complete_cleanup(self._celigo.abort_controller_operation()) + raise + + async def _load_firing_targets( + self, + voltage_offsets: List[Tuple[float, float]], + center_voltages: Tuple[float, float], + ) -> None: + """Load voltage-offset targets around an explicit logical laser center.""" + if not voltage_offsets: + raise ValueError("voltage_offsets must not be empty") + await self._assert_safe() + payload = struct.pack(">i", len(voltage_offsets)) + hardware = self._celigo.config.hardware + for x_dac_count, y_dac_count in _encode_firing_targets( + hardware.x_galvo, + hardware.y_galvo, + voltage_offsets, + center_voltages, + ): + payload += struct.pack(">HH", x_dac_count, y_dac_count) + await self._celigo.send_command(_CMD_LOAD_FIRING_TABLE, payload) + if not await self._celigo.wait_for_controller_ready(timeout=5.0): + raise TimeoutError("Controller did not finish loading laser targets") + + async def fire_targets( + self, + voltage_offsets: List[Tuple[float, float]], + laser_index: int, + pulses: int, + delay_between_pulses: float = 0.0, + center_voltages: Optional[Tuple[float, float]] = None, + ) -> None: + """Load and fire galvo targets in table-sized chunks. + + ``delay_between_pulses`` is expressed in seconds. + """ + if not voltage_offsets: + raise ValueError("voltage_offsets must not be empty") + if laser_index not in (0, 1): + raise ValueError("laser_index must be 0 (LASER_1) or 1 (LASER_2)") + if not 0 < pulses <= 0xFFFFFFFF: + raise ValueError("pulses must fit in a positive unsigned 32-bit integer") + delay_ticks = _delay_seconds_to_controller_ticks(delay_between_pulses) + await self._assert_safe() + if center_voltages is None: + optical = self._celigo.config.galvo_optical_calibration + center_voltages = ( + optical.x.laser_center_voltage if laser_index == 0 else optical.x.uv_laser_center_voltage, + optical.y.laser_center_voltage if laser_index == 0 else optical.y.uv_laser_center_voltage, + ) + table_size = (await self._celigo.galvo.request_controller_status()).fire_table_size + if table_size <= 0: + raise CeligoError(f"Controller reported invalid laser firing-table size {table_size}") + try: + for start_index in range(0, len(voltage_offsets), table_size): + chunk = voltage_offsets[start_index : start_index + table_size] + await self._load_firing_targets(chunk, center_voltages) + payload = struct.pack(">HIIH", laser_index, pulses, delay_ticks, 0) + # Loading and waiting can take long enough for the door/interlock state to change. + await self._assert_safe() + await self._celigo.send_command(_CMD_TARGETED_FIRE, payload) + timeout = max( + 5.0, + self._celigo.move_timeout, + len(chunk) * max(0, pulses - 1) * delay_between_pulses + 5.0, + ) + if not await self._celigo.wait_for_controller_ready(timeout=timeout): + raise TimeoutError("Targeted laser firing did not complete") + status = await self._celigo.galvo.request_controller_status() + if status.fire_table_index != status.points_loaded: + raise CeligoError( + f"Laser firing stopped at target {status.fire_table_index}/{status.points_loaded}" + ) + except BaseException: + with contextlib.suppress(Exception): + await complete_cleanup(self._celigo.abort_controller_operation()) + raise + + async def fire_grid( + self, + laser_index: int, + spacing_voltages: Tuple[float, float], + size_voltages: Tuple[float, float], + center_voltages: Tuple[float, float], + pulses: int, + repeats: int, + delay_between_repeats: float = 0.0, + pattern_bitmask: int = 0x1E, + ) -> None: + """Fire a firmware-generated galvo grid. + + ``delay_between_repeats`` is expressed in seconds. The default pattern bitmask + selects the full grid. + """ + if laser_index not in (0, 1): + raise ValueError("laser_index must be 0 (LASER_1) or 1 (LASER_2)") + if not 0 < pulses <= 0x7FFFFFFF or not 0 < repeats <= 0x7FFFFFFF: + raise ValueError("pulses and repeats must fit in positive signed 32-bit integers") + delay_ticks = _delay_seconds_to_controller_ticks(delay_between_repeats) + hardware = self._celigo.config.hardware + grid = _encode_grid( + hardware.x_galvo, + hardware.y_galvo, + spacing_voltages, + size_voltages, + center_voltages, + ) + payload = struct.pack( + ">HHHHHHHiiiiH", + laser_index, + grid.x_spacing, + grid.y_spacing, + grid.x_size, + grid.y_size, + grid.x_center, + grid.y_center, + pulses, + repeats, + delay_ticks, + pattern_bitmask, + 0, + ) + await self._assert_safe() + try: + await self._celigo.send_command(_CMD_FIRE_GALVO_GRID, payload) + timeout = max( + 5.0, + self._celigo.move_timeout, + max(0, repeats - 1) * delay_between_repeats + 5.0, + ) + if not await self._celigo.wait_for_controller_ready(timeout=timeout): + raise TimeoutError("Laser grid firing did not complete") + except BaseException: + with contextlib.suppress(Exception): + await complete_cleanup(self._celigo.abort_controller_operation()) + raise diff --git a/pylabrobot/revvity/celigo/motion.py b/pylabrobot/revvity/celigo/motion.py new file mode 100644 index 00000000000..b91bdeb0911 --- /dev/null +++ b/pylabrobot/revvity/celigo/motion.py @@ -0,0 +1,1036 @@ +"""Motor, linear-axis, and filter-wheel components for the Celigo.""" + +from __future__ import annotations + +import asyncio +import contextlib +import math +import struct +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, Optional, Tuple + +from pylabrobot.revvity.celigo.config import ( + AxisConfig, + CeligoConfig, + FilterWheelConfig, + LinearAxisConfig, +) +from pylabrobot.revvity.celigo.errors import CeligoError +from pylabrobot.revvity.celigo.protocol import complete_cleanup, validate_payload_length + +if TYPE_CHECKING: + from pylabrobot.revvity.celigo.celigo import Celigo + +LinearAxisName = Literal["x", "y", "z"] +MotorControllerFirmwareVersion = Tuple[int, int] + +# AllMotion status byte: 0x20 set == ready, low nibble == error code. +_EZ_READY_BIT = 0x20 +_EZ_ERROR_MASK = 0x0F + +# EZStepper ASCII command codes. +_EZ_MOVE_ABSOLUTE = "A" +_EZ_MOVE_POSITIVE = "P" +_EZ_MOVE_NEGATIVE = "D" +_EZ_HOME = "Z" +_EZ_SET_VELOCITY = "V" +_EZ_SET_ACCELERATION = "L" +_EZ_SET_MOVE_CURRENT = "m" +_EZ_SET_HOLD_CURRENT = "h" +_EZ_SET_POLARITY = "f" +_EZ_SET_POSITIVE_DIRECTION = "F" +_EZ_SET_SPECIAL_MODE = "N" +_EZ_SET_MODE = "n" +_EZ_SET_ENCODER_RATIO = "aE" +_EZ_SET_OVERLOAD_TIMEOUT = "au" +_EZ_SET_COARSE_WINDOW = "aC" +_EZ_SET_FINE_WINDOW = "ac" +_EZ_SET_INTEGRATION_PERIOD = "x" +_EZ_SET_RESPONSE_TIME = "aP" +_EZ_SET_BACKLASH = "K" +_EZ_SET_S_CURVE = "aj" +_EZ_TERMINATE = "T" +_EZ_QUERY_FIRMWARE = "&" +_EZ_QUERY = "?" +_EZ_QUERY_STATUS = "Q" +_EZ_QUERY_ENCODER_POSITION = 8 +_EZ_QUERY_FLAGS = 4 + +_EZ_MODE_ENABLE_LIMITS = 0x02 +_EZ_MODE_ENABLE_POSITION_CORRECTION = 0x08 +_EZ_MODE_ENABLE_STEP_AND_DIRECTION = 0x20 +_EZ_MODE_ENABLE_MOTOR_SLAVE_TO_ENCODER = 0x40 + +_EZ_SPECIAL_ENCODER_NO_INDEX = 1 +_EZ_SPECIAL_ENCODER_WITH_INDEX = 2 +_EZ_SPECIAL_ENCODER_WITH_INDEX_ACCURATE = 6 + +_LIMIT_OPTO_1 = 0x04 +_LIMIT_OPTO_2 = 0x08 +_LIMIT_ALL = 0x1F +_ETX = "\x03" + +# Controller-board motor-tunnel opcodes and statuses. +_CMD_MOTOR_QUERY = 44 +_CMD_MOTOR_QUERY_WITH_LENGTH = 47 +_LENGTH_PREFIXED_COMMAND_MINIMUM_FIRMWARE = (1, 3, 0) +_NO_CONTROLLER_ERROR = 0 +_NO_MOTOR_NUMBER = 5011 +_BAD_MOTOR_NUMBER = 5012 +_MOTOR_COMMUNICATION_ERROR = 5025 +_MOTOR_QUERY_ATTEMPTS = 5 +_MOTOR_COMMAND_MAX_BYTES = 512 +_STX_BYTE = b"\x02" +_ETX_BYTE = b"\x03" + + +@dataclass(frozen=True) +class _MotionProfile: + """EZStepper motion values in controller-native units. + + Linear-axis rates are converted from millimeters using ``mm_per_encoder_tick``. + Optical-axis configurations already use encoder rates. Current values are percentages + of the motor's rated current. + """ + + velocity_ticks_per_second: int + acceleration_ticks_per_second_squared: int + move_current_percent: Optional[int] + hold_current_percent: Optional[int] + + +@dataclass(frozen=True) +class _EZResponse: + """Parsed AllMotion reply: ready flag, error code, and response text.""" + + ready: bool + error_code: int + response_text: str + + @property + def ok(self) -> bool: + return self.error_code == 0 + + +def _ez_motor_address(axis_index: int) -> str: + """Return the AllMotion address character for a motor index.""" + return str(axis_index) if 0 < axis_index < 10 else chr(48 + axis_index) + + +def _make_ez_command( + axis_index: int, + command_tokens: str, + execute: bool, +) -> str: + """Build an EZStepper command string: ``/[R]\\r``.""" + return f"/{_ez_motor_address(axis_index)}{command_tokens}{'R' if execute else ''}\r" + + +def _parse_ez_response(raw_response: str) -> _EZResponse: + """Parse an AllMotion reply string.""" + master_prefix_index = raw_response.find("/0") + status_position: Optional[int] + if master_prefix_index >= 0 and master_prefix_index + 2 < len(raw_response): + status_position = master_prefix_index + 2 + else: + status_position = next( + (index for index, character in enumerate(raw_response) if ord(character) & 0x40), + None, + ) + if status_position is None: + raise CeligoError(f"No EZStepper status byte in reply: {raw_response!r}") + status = ord(raw_response[status_position]) + response_text = raw_response[status_position + 1 :] + for terminator in (_ETX, "\r", "\n"): + cut = response_text.find(terminator) + if cut >= 0: + response_text = response_text[:cut] + return _EZResponse( + ready=bool(status & _EZ_READY_BIT), + error_code=status & _EZ_ERROR_MASK, + response_text=response_text, + ) + + +def _encode_oem_command(command: str) -> bytes: + start = command.rfind("/") + end = command.find("\r", start + 1) + if start < 0 or end <= start + 1: + raise ValueError(f"Invalid EZStepper command framing: {command!r}") + command_body = command[start + 1 : end] + address, command_tokens = command_body[0], command_body[1:] + frame = _STX_BYTE + f"{address}1{command_tokens}".encode("ascii") + _ETX_BYTE + checksum = 0 + for value in frame: + checksum ^= value + return frame + bytes([checksum]) + + +def _decode_oem_response(response_packet: bytes) -> str: + start = response_packet.rfind(_STX_BYTE) + if start < 0: + raise CeligoError("Invalid OEM motor response: missing STX") + end = response_packet.find(_ETX_BYTE, start + 1) + if end < 0: + raise CeligoError("Invalid OEM motor response: missing ETX") + if end - start - 1 < 2: + raise CeligoError("Invalid OEM motor response: payload is too short") + if end + 1 >= len(response_packet): + raise CeligoError("Invalid OEM motor response: missing checksum") + + calculated_checksum = 0 + for value in response_packet[start : end + 1]: + calculated_checksum ^= value + received_checksum = response_packet[end + 1] + if received_checksum != calculated_checksum: + raise CeligoError( + "OEM motor response checksum failure: " + f"received {received_checksum:#04x}, calculated {calculated_checksum:#04x}" + ) + return "/" + response_packet[start + 1 : end].decode("latin-1") + + +class MotorController: + """EZStepper command transport tunneled through the Celigo controller board.""" + + def __init__(self, board: "Celigo") -> None: + self._board = board + + @property + def move_timeout(self) -> float: + return self._board.move_timeout + + @property + def _uses_length_prefixed_commands(self) -> bool: + firmware_version = self._board.controller_firmware_version + if firmware_version is None: + raise CeligoError("Motor-command framing is unavailable before controller identification") + return firmware_version >= _LENGTH_PREFIXED_COMMAND_MINIMUM_FIRMWARE + + async def send_command(self, command: str) -> str: + """Send a complete EZStepper command string and return its device reply.""" + uses_length_prefixed_commands = self._uses_length_prefixed_commands + encoded_command = ( + _encode_oem_command(command) if uses_length_prefixed_commands else command.encode("ascii") + ) + if len(encoded_command) > _MOTOR_COMMAND_MAX_BYTES: + raise ValueError( + f"Motor command is {len(encoded_command)} bytes; maximum is {_MOTOR_COMMAND_MAX_BYTES}" + ) + payload = encoded_command if uses_length_prefixed_commands else encoded_command + b"\x00" + opcode = _CMD_MOTOR_QUERY_WITH_LENGTH if uses_length_prefixed_commands else _CMD_MOTOR_QUERY + attempts = _MOTOR_QUERY_ATTEMPTS if uses_length_prefixed_commands else 1 + + for attempt in range(attempts): + response = await self._board.send_command(opcode, payload) + validate_payload_length(response, 2, "motor query") + (extended_status,) = struct.unpack_from(">H", response, 0) + if extended_status in (_NO_MOTOR_NUMBER, _BAD_MOTOR_NUMBER): + raise CeligoError( + f"Invalid motor number (status {extended_status}) for command {command!r}" + ) + if extended_status == _MOTOR_COMMUNICATION_ERROR: + if uses_length_prefixed_commands and attempt < attempts - 1: + continue + raise CeligoError(f"Motor communication error for command {command!r}") + if extended_status != _NO_CONTROLLER_ERROR: + raise CeligoError(f"Unexpected motor status {extended_status} for command {command!r}") + + validate_payload_length(response, 4, "motor query") + (response_length,) = struct.unpack_from(">H", response, 2) + validate_payload_length(response, 4 + response_length, "motor query") + motor_response = response[4 : 4 + response_length] + if not uses_length_prefixed_commands: + return motor_response.decode("latin-1") + try: + return _decode_oem_response(motor_response) + except CeligoError: + if attempt == attempts - 1: + raise + + raise CeligoError(f"Motor query failed after {attempts} attempts: {command!r}") + + +def _parse_motor_controller_firmware_version( + response_text: str, +) -> MotorControllerFirmwareVersion: + """Extract the numeric version from an EZStepper identification response.""" + for token in response_text.replace(",", " ").split(): + if token[:1].lower() != "v": + continue + major, separator, minor = token[1:].partition(".") + if separator and major.isdigit() and minor.isdigit(): + return int(major), int(minor) + raise CeligoError(f"Could not parse EZStepper firmware response {response_text!r}") + + +class StepperMotor: + """One addressed EZStepper motor on the Celigo controller.""" + + def __init__(self, controller: MotorController, axis_index: int) -> None: + if axis_index <= 0: + raise ValueError("axis_index must be positive") + self._controller = controller + self.axis_index = axis_index + + async def send_command( + self, + command_tokens: str, + execute: bool = True, + ) -> _EZResponse: + """Send EZStepper command tokens to this motor.""" + command = _make_ez_command( + self.axis_index, + command_tokens, + execute, + ) + return _parse_ez_response(await self._controller.send_command(command)) + + async def request_motor_controller_firmware_version( + self, + ) -> MotorControllerFirmwareVersion: + """Read this motor's EZStepper controller firmware version.""" + response = await self.send_command(_EZ_QUERY_FIRMWARE, execute=False) + if not response.ok: + raise CeligoError( + f"motor {self.axis_index} firmware query failed (code {response.error_code})" + ) + return _parse_motor_controller_firmware_version(response.response_text) + + async def request_encoder_ratio(self) -> float: + """Read the configured ratio of encoder ticks to motor ticks.""" + response = await self.send_command( + f"{_EZ_QUERY}{_EZ_SET_ENCODER_RATIO}", + execute=False, + ) + if not response.ok: + raise CeligoError( + f"motor {self.axis_index} encoder-ratio query failed (code {response.error_code})" + ) + return int(response.response_text) / 1000.0 + + async def request_encoder_ticks(self) -> int: + """Read the current encoder position in ticks.""" + response = await self.send_command( + f"{_EZ_QUERY}{_EZ_QUERY_ENCODER_POSITION}", + execute=False, + ) + if not response.ok: + raise CeligoError( + f"motor {self.axis_index} encoder query failed (code {response.error_code})" + ) + return int(response.response_text) + + async def wait_until_ready(self, timeout: Optional[float] = None) -> int: + """Wait until the motor is ready and return its settled encoder position.""" + selected_timeout = self._controller.move_timeout if timeout is None else timeout + deadline = time.monotonic() + selected_timeout + while time.monotonic() < deadline: + response = await self.send_command(_EZ_QUERY_STATUS, execute=False) + if not response.ok: + raise CeligoError(f"motor {self.axis_index} reported error {response.error_code}") + if response.ready: + return await self.request_encoder_ticks() + await asyncio.sleep(0.05) + raise TimeoutError(f"motor {self.axis_index} not ready within timeout") + + async def _set_mode(self, motor_mode: int) -> None: + response = await self.send_command(f"{_EZ_SET_MODE}{motor_mode}") + if not response.ok: + raise CeligoError(f"motor {self.axis_index} mode change failed (code {response.error_code})") + + async def _set_parameter( + self, + parameter_token: str, + parameter_value: int, + operation_description: str, + ) -> None: + response = await self.send_command(f"{parameter_token}{parameter_value}") + if not response.ok: + raise CeligoError( + f"motor {self.axis_index} {operation_description} failed (code {response.error_code})" + ) + + async def _terminate(self) -> None: + response = await self.send_command(_EZ_TERMINATE, execute=False) + if not response.ok: + raise CeligoError(f"motor {self.axis_index} stop failed (code {response.error_code})") + + +class Axis: + """One configured motorized Celigo mechanism.""" + + def __init__( + self, + controller: MotorController, + name: str, + config: AxisConfig, + ) -> None: + if not config.enabled or config.axis_index <= 0: + raise ValueError("Axis requires an enabled configuration with a positive axis_index") + self._controller = controller + self.name = name + self.config = config + self.motor = StepperMotor(controller, config.axis_index) + self._supports_accurate_encoder_index = False + self._initialized = False + + @property + def axis_index(self) -> int: + return self.motor.axis_index + + @property + def is_initialized(self) -> bool: + return self._initialized + + def _rate_to_encoder_tick_rate(self, configured_rate: float) -> int: + if not math.isfinite(configured_rate) or configured_rate <= 0: + raise CeligoError( + f"{self.config.motion_name or f'motor {self.axis_index}'} has invalid " + f"configured rate {configured_rate}" + ) + encoder_tick_rate = round(configured_rate) + if encoder_tick_rate <= 0: + raise CeligoError( + f"{self.config.motion_name or f'motor {self.axis_index}'} rate " + f"{configured_rate} rounds to zero encoder ticks" + ) + return encoder_tick_rate + + def _motor_mode(self, enable_position_correction: bool = True) -> int: + motor_mode = 0 + if self.config.mode_enable_limits: + motor_mode |= _EZ_MODE_ENABLE_LIMITS + if enable_position_correction and self.config.mode_enable_position_correction: + motor_mode |= _EZ_MODE_ENABLE_POSITION_CORRECTION + if self.config.mode_enable_step_and_direction: + motor_mode |= _EZ_MODE_ENABLE_STEP_AND_DIRECTION + if self.config.mode_enable_motor_slave_to_encoder: + motor_mode |= _EZ_MODE_ENABLE_MOTOR_SLAVE_TO_ENCODER + return motor_mode + + def _motion_profile(self) -> _MotionProfile: + return _MotionProfile( + velocity_ticks_per_second=self._rate_to_encoder_tick_rate(self.config.max_velocity), + acceleration_ticks_per_second_squared=self._rate_to_encoder_tick_rate( + self.config.max_acceleration + ), + move_current_percent=self.config.moving_current_percentage or None, + hold_current_percent=self.config.holding_current_percentage or None, + ) + + async def _initialize(self) -> None: + """Replay the vendor's per-motor initialization configuration.""" + self._initialized = False + self._supports_accurate_encoder_index = False + motor_controller_firmware_version = await self.motor.request_motor_controller_firmware_version() + await self.motor._terminate() + if motor_controller_firmware_version >= (7, 12): + special_mode_response = await self.motor.send_command(f"{_EZ_SET_SPECIAL_MODE}32") + if not special_mode_response.ok: + raise CeligoError( + f"motor {self.axis_index} special-mode initialization failed " + f"(code {special_mode_response.error_code})" + ) + + profile = self._motion_profile() + command_tokens = ( + f"{_EZ_SET_POSITIVE_DIRECTION}{0 if self.config.default_positive_direction else 1}" + f"{_EZ_SET_POLARITY}{self.config.limit_polarity}" + f"{_EZ_SET_MOVE_CURRENT}{self.config.moving_current_percentage}" + f"{_EZ_SET_HOLD_CURRENT}{self.config.holding_current_percentage}" + f"{_EZ_SET_ENCODER_RATIO}{round(self.config.encoder_to_motor_tick_ratio * 1000)}" + ) + if self.config.mode_enable_position_correction: + command_tokens += ( + f"{_EZ_SET_OVERLOAD_TIMEOUT}{self.config.moving_overload_limit}" + f"{_EZ_SET_COARSE_WINDOW}{self.config.coarse_position_error_window}" + f"{_EZ_SET_FINE_WINDOW}{self.config.fine_position_error_window}" + f"{_EZ_SET_INTEGRATION_PERIOD}{self.config.gain}" + ) + command_tokens += ( + f"{_EZ_SET_VELOCITY}{profile.velocity_ticks_per_second}" + f"{_EZ_SET_ACCELERATION}{profile.acceleration_ticks_per_second_squared}" + f"{_EZ_SET_RESPONSE_TIME}{self.config.motor_response_time}" + ) + response: Optional[_EZResponse] = None + last_error: Optional[CeligoError] = None + for attempt in range(5): + try: + response = await self.motor.send_command(command_tokens) + if response.ok: + break + last_error = CeligoError( + f"motor {self.axis_index} initialization failed (code {response.error_code})" + ) + except CeligoError as exc: + last_error = exc + if attempt < 4: + await asyncio.sleep(0.1) + if response is None or not response.ok: + raise CeligoError( + f"motor {self.axis_index} initialization failed after five attempts" + ) from last_error + + await self.motor._set_mode(self._motor_mode(enable_position_correction=False)) + if self.config.s_curve_support: + await self.motor._set_parameter( + _EZ_SET_S_CURVE, + self.config.max_s_acceleration, + "S-curve setup", + ) + self._supports_accurate_encoder_index = motor_controller_firmware_version >= (7, 16) + self._initialized = True + + async def request_encoder_ticks(self) -> int: + return await self.motor.request_encoder_ticks() + + async def request_encoder_ratio(self) -> float: + return await self.motor.request_encoder_ratio() + + async def request_limit_flags(self) -> int: + """Read and polarity-correct the motor's opto/limit input flags.""" + response = await self.motor.send_command( + f"{_EZ_QUERY}{_EZ_QUERY_FLAGS}", + execute=False, + ) + if not response.ok: + raise CeligoError(f"motor {self.axis_index} limit query failed (code {response.error_code})") + flags = int(response.response_text) & _LIMIT_ALL + if self.config.limit_polarity == 1: + flags = (~flags) & _LIMIT_ALL + return flags + + async def request_is_negative_limit_active(self) -> bool: + """Return whether the negative-travel opto input is active.""" + return bool(await self.request_limit_flags() & _LIMIT_OPTO_1) + + async def request_is_positive_limit_active(self) -> bool: + """Return whether the positive-travel opto input is active.""" + return bool(await self.request_limit_flags() & _LIMIT_OPTO_2) + + async def _restore_homing_configuration(self) -> None: + await self.motor._set_parameter( + _EZ_SET_BACKLASH, + self.config.backlash_compensation, + "backlash restore", + ) + if self.config.s_curve_support: + await self.motor._set_parameter( + _EZ_SET_S_CURVE, + self.config.max_s_acceleration, + "S-curve restore", + ) + await self.motor._set_mode(self._motor_mode()) + + async def _move_homing_relative_ticks( + self, + positive: bool, + distance_ticks: int, + velocity_ticks_per_second: int, + ) -> int: + if distance_ticks <= 0: + raise ValueError("homing distance must be positive") + acceleration = self._rate_to_encoder_tick_rate(self.config.max_acceleration) + direction = _EZ_MOVE_POSITIVE if positive else _EZ_MOVE_NEGATIVE + response = await self.motor.send_command( + f"{_EZ_SET_ACCELERATION}{acceleration}" + f"{_EZ_SET_VELOCITY}{velocity_ticks_per_second}" + f"{direction}{distance_ticks}" + ) + if not response.ok: + raise CeligoError(f"motor {self.axis_index} homing move failed (code {response.error_code})") + timeout = max( + self._controller.move_timeout, + distance_ticks / max(1, velocity_ticks_per_second) + 2.0, + ) + return await self.motor.wait_until_ready(timeout) + + async def _home_to_encoder_index( + self, + search_distance_ticks: int, + velocity_ticks_per_second: int, + special_encoder_mode: int, + timeout: Optional[float] = None, + restore_motor_mode: Optional[int] = None, + ) -> int: + await self.motor._set_mode(0) + try: + acceleration = self._rate_to_encoder_tick_rate(self.config.max_acceleration) + response = await self.motor.send_command( + f"{_EZ_SET_ACCELERATION}{acceleration}" + f"{_EZ_SET_VELOCITY}{velocity_ticks_per_second}" + f"{_EZ_SET_SPECIAL_MODE}{special_encoder_mode}" + f"{_EZ_HOME}{search_distance_ticks}" + ) + if not response.ok: + raise CeligoError(f"motor {self.axis_index} index home failed (code {response.error_code})") + return await self.motor.wait_until_ready(timeout) + except BaseException: + with contextlib.suppress(Exception): + await complete_cleanup(self.motor._terminate()) + raise + finally: + selected_mode = self._motor_mode() if restore_motor_mode is None else restore_motor_mode + await complete_cleanup(self.motor._set_mode(selected_mode)) + + async def move_to_ticks( + self, + target_encoder_ticks: int, + velocity_ticks_per_second: Optional[int] = None, + arrival_tolerance_ticks: Optional[int] = None, + ) -> int: + """Move to an encoder target and verify the settled position.""" + selected_velocity = ( + self._rate_to_encoder_tick_rate(self.config.max_velocity) + if velocity_ticks_per_second is None + else velocity_ticks_per_second + ) + if selected_velocity <= 0: + raise ValueError("velocity_ticks_per_second must be positive") + acceleration = self._rate_to_encoder_tick_rate(self.config.max_acceleration) + temporary_hold_current = min(50, self.config.moving_current_percentage) + if arrival_tolerance_ticks is not None and arrival_tolerance_ticks < 0: + raise ValueError("arrival_tolerance_ticks must be non-negative") + selected_tolerance = ( + self.config.fine_position_error_window + if arrival_tolerance_ticks is None + else arrival_tolerance_ticks + ) + last_position: Optional[int] = None + for attempt in range(3): + try: + response = await self.motor.send_command( + f"{_EZ_SET_HOLD_CURRENT}{temporary_hold_current}" + f"{_EZ_SET_ACCELERATION}{acceleration}" + f"{_EZ_SET_VELOCITY}{selected_velocity}" + f"{_EZ_MOVE_ABSOLUTE}{target_encoder_ticks}" + ) + if not response.ok: + raise CeligoError(f"motor {self.axis_index} move failed (code {response.error_code})") + last_position = await self.motor.wait_until_ready() + except BaseException as exc: + with contextlib.suppress(Exception): + await complete_cleanup(self.motor._terminate()) + with contextlib.suppress(Exception): + await complete_cleanup( + self.motor._set_parameter( + _EZ_SET_HOLD_CURRENT, + self.config.holding_current_percentage, + "hold-current restore", + ) + ) + if isinstance(exc, (CeligoError, TimeoutError)) and attempt < 2: + continue + raise + await complete_cleanup( + self.motor._set_parameter( + _EZ_SET_HOLD_CURRENT, + self.config.holding_current_percentage, + "hold-current restore", + ) + ) + if abs(last_position - target_encoder_ticks) <= selected_tolerance: + return last_position + raise CeligoError( + f"motor {self.axis_index} stopped at {last_position}, target " + f"{target_encoder_ticks}, tolerance {selected_tolerance}" + ) + + +class LinearAxis(Axis): + """A configured X, Y, or Z axis with millimeter movement and homing.""" + + config: LinearAxisConfig + + def __init__( + self, + controller: MotorController, + name: LinearAxisName, + config: LinearAxisConfig, + ) -> None: + super().__init__(controller, name, config) + self._has_position_reference = False + + @property + def has_position_reference(self) -> bool: + """Whether this process knows the axis position relative to its physical datum.""" + return self._has_position_reference + + async def _initialize(self) -> None: + """Forget any process-local datum and replay the motor configuration.""" + self._has_position_reference = False + await super()._initialize() + + def _rate_to_encoder_tick_rate(self, configured_rate: float) -> int: + if self.config.mm_per_encoder_tick <= 0: + raise CeligoError(f"axis {self.name} has invalid mm_per_encoder_tick") + return super()._rate_to_encoder_tick_rate(configured_rate / self.config.mm_per_encoder_tick) + + def mm_to_encoder_ticks(self, position_mm: float) -> int: + """Convert an absolute stage position in millimeters to encoder ticks.""" + if self.config.mm_per_encoder_tick <= 0: + raise CeligoError(f"axis {self.name} has invalid mm_per_encoder_tick") + if not math.isfinite(position_mm): + raise ValueError("position_mm must be finite") + direction = -1.0 if self.config.invert_axis_direction else 1.0 + return round( + (position_mm * direction + self.config.home_offset) / self.config.mm_per_encoder_tick + ) + + def encoder_ticks_to_mm(self, encoder_ticks: int) -> float: + """Convert an absolute encoder position to stage millimeters.""" + if self.config.mm_per_encoder_tick <= 0: + raise CeligoError(f"axis {self.name} has invalid mm_per_encoder_tick") + direction = -1.0 if self.config.invert_axis_direction else 1.0 + return (encoder_ticks * self.config.mm_per_encoder_tick - self.config.home_offset) * direction + + async def request_position(self) -> float: + """Read the current axis position in millimeters.""" + return self.encoder_ticks_to_mm(await self.request_encoder_ticks()) + + def encoder_bounds(self) -> Tuple[int, int]: + """Return the configured encoder bounds in ascending order.""" + if self.config.mm_per_encoder_tick <= 0: + raise CeligoError(f"axis {self.name} has invalid mm_per_encoder_tick") + if self.config.max_position <= self.config.min_position: + raise CeligoError(f"axis {self.name} has invalid configured position bounds") + endpoints = ( + self.mm_to_encoder_ticks(self.config.min_position), + self.mm_to_encoder_ticks(self.config.max_position), + ) + return min(endpoints), max(endpoints) + + def _validate_target(self, target_encoder_ticks: int) -> None: + minimum_ticks, maximum_ticks = self.encoder_bounds() + if not minimum_ticks <= target_encoder_ticks <= maximum_ticks: + raise CeligoError( + f"axis {self.name} target {target_encoder_ticks} is outside configured " + f"encoder range {minimum_ticks}..{maximum_ticks}" + ) + + async def move_to_ticks( + self, + target_encoder_ticks: int, + velocity_ticks_per_second: Optional[int] = None, + arrival_tolerance_ticks: Optional[int] = None, + ) -> int: + if not self.has_position_reference: + raise CeligoError( + f"axis {self.name} has no position reference; call await " + f"celigo.{self.name}_axis.home() before moving it" + ) + if ( + arrival_tolerance_ticks is not None + and arrival_tolerance_ticks > self.config.fine_position_error_window + ): + raise CeligoError("requested tolerance exceeds the configured fine-position window") + self._validate_target(target_encoder_ticks) + return await super().move_to_ticks( + target_encoder_ticks, + velocity_ticks_per_second=velocity_ticks_per_second, + arrival_tolerance_ticks=arrival_tolerance_ticks, + ) + + async def move_to( + self, + position_mm: float, + tolerance_mm: Optional[float] = None, + ) -> float: + """Move to an absolute position in millimeters.""" + if self.config.mm_per_encoder_tick <= 0: + raise CeligoError(f"axis {self.name} has invalid mm_per_encoder_tick") + low_mm, high_mm = sorted((self.config.min_position, self.config.max_position)) + if not low_mm <= position_mm <= high_mm: + raise CeligoError( + f"axis {self.name} target {position_mm:g} mm is outside configured range " + f"{low_mm:g}..{high_mm:g} mm" + ) + tolerance_ticks = None + if tolerance_mm is not None: + if tolerance_mm < 0: + raise ValueError("tolerance_mm must be non-negative") + tolerance_ticks = round(tolerance_mm / self.config.mm_per_encoder_tick) + settled_ticks = await self.move_to_ticks( + self.mm_to_encoder_ticks(position_mm), + arrival_tolerance_ticks=tolerance_ticks, + ) + return self.encoder_ticks_to_mm(settled_ticks) + + async def assume_homed(self) -> int: + """Adopt an in-range encoder position from software that already homed the axis. + + This cannot prove that encoder zero matches the physical datum. Normal workflows + should call :meth:`home`. + """ + position = await self.request_encoder_ticks() + self._validate_target(position) + await self.motor._set_mode(self._motor_mode()) + self._has_position_reference = True + return position + + async def home(self) -> int: + """Home this axis with its configured vendor algorithm.""" + supported = { + "Normal", + "Normal_Accurate", + "NormalWithHardstopCheck", + "NormalWithHardstopCheck_Accurate", + } + if self.config.home_type not in supported: + raise CeligoError(f"axis {self.name!r} has unsupported home type {self.config.home_type!r}") + if not self.config.mode_enable_limits or not self.config.negative_limit: + raise CeligoError(f"axis {self.name!r} homing requires a configured negative limit") + if self.config.homing_short_move <= 0: + raise CeligoError(f"axis {self.name!r} has an invalid homing backoff distance") + if not self.is_initialized: + await self._initialize() + + self._has_position_reference = False + homing_motor_mode = self._motor_mode(enable_position_correction=False) + maximum_velocity = self._rate_to_encoder_tick_rate(self.config.max_velocity) + homing_velocity = self._rate_to_encoder_tick_rate(self.config.homing_velocity) + index_velocity = self._rate_to_encoder_tick_rate(self.config.index_velocity) + + async def terminate_and_restore() -> None: + with contextlib.suppress(Exception): + await complete_cleanup(self.motor._terminate()) + with contextlib.suppress(Exception): + await complete_cleanup(self._restore_homing_configuration()) + + try: + await self.motor._set_mode(homing_motor_mode) + if self.config.s_curve_support: + await self.motor._set_parameter(_EZ_SET_S_CURVE, 0, "S-curve disable") + await self.motor._set_parameter(_EZ_SET_BACKLASH, 0, "backlash disable") + + initial_encoder_ticks = await self.request_encoder_ticks() + await self._move_homing_relative_ticks(True, 5, maximum_velocity) + if await self.request_encoder_ticks() == initial_encoder_ticks: + await self._move_homing_relative_ticks(False, 10, maximum_velocity) + if await self.request_encoder_ticks() == initial_encoder_ticks: + raise CeligoError(f"axis {self.name!r} encoder did not respond to the homing probe") + + await self._move_homing_relative_ticks(False, 25000, homing_velocity) + if not (await self.request_limit_flags() & _LIMIT_OPTO_1): + raise CeligoError( + f"axis {self.name!r} stopped without activating its negative-limit sensor" + ) + await asyncio.sleep(0.05) + await self._move_homing_relative_ticks( + True, + self.config.homing_short_move, + homing_velocity, + ) + if await self.request_limit_flags() & _LIMIT_OPTO_1: + raise CeligoError(f"axis {self.name!r} negative-limit sensor did not clear after backoff") + await asyncio.sleep(0.05) + + if self.config.home_type.startswith("NormalWithHardstopCheck"): + search_distance_ticks = 25000 + special_mode = _EZ_SPECIAL_ENCODER_NO_INDEX + else: + search_distance_ticks = self.config.homing_short_move * 2 + special_mode = ( + _EZ_SPECIAL_ENCODER_WITH_INDEX_ACCURATE + if self.config.home_type == "Normal_Accurate" and self._supports_accurate_encoder_index + else _EZ_SPECIAL_ENCODER_WITH_INDEX + ) + + await self._home_to_encoder_index( + search_distance_ticks, + index_velocity, + special_mode, + timeout=max( + self._controller.move_timeout, + search_distance_ticks / max(1, index_velocity) + 2.0, + ), + restore_motor_mode=homing_motor_mode, + ) + await super().move_to_ticks( + 0, + velocity_ticks_per_second=maximum_velocity, + ) + await self._restore_homing_configuration() + settled_ticks = await super().move_to_ticks( + self.mm_to_encoder_ticks(self.config.min_position) + ) + self._has_position_reference = True + return settled_ticks + except BaseException: + self._has_position_reference = False + await terminate_and_restore() + raise + + async def _move_relative_to_limit( + self, + distance_ticks: int, + move_current_percent: Optional[int] = None, + ) -> None: + """Move a trusted axis relatively toward a limit.""" + if distance_ticks == 0: + raise ValueError("relative move distance must be non-zero") + if not self.has_position_reference: + raise CeligoError(f"axis {self.name} has no position reference") + profile = self._motion_profile() + selected_current = ( + profile.move_current_percent if move_current_percent is None else move_current_percent + ) + command_tokens = "" + if selected_current is not None: + command_tokens += f"{_EZ_SET_MOVE_CURRENT}{selected_current}" + if profile.hold_current_percent is not None: + command_tokens += f"{_EZ_SET_HOLD_CURRENT}{profile.hold_current_percent}" + command_tokens += ( + f"{_EZ_SET_VELOCITY}{profile.velocity_ticks_per_second}" + f"{_EZ_SET_ACCELERATION}{profile.acceleration_ticks_per_second_squared}" + f"{_EZ_MOVE_POSITIVE if distance_ticks > 0 else _EZ_MOVE_NEGATIVE}" + f"{abs(distance_ticks)}" + ) + response = await self.motor.send_command(command_tokens) + if not response.ok: + raise CeligoError(f"axis {self.name} relative move error (code {response.error_code})") + await self.motor.wait_until_ready() + + def _limit_move_distance_ticks(self) -> int: + """Return a relative distance guaranteed to exceed configured travel.""" + if self.config.mm_per_encoder_tick <= 0: + raise CeligoError(f"Cannot derive {self.name.upper()} limit move without axis configuration") + configured_travel_ticks = ( + abs(self.config.max_position - self.config.min_position) / self.config.mm_per_encoder_tick + ) + return math.ceil(configured_travel_ticks) + abs(self.config.homing_short_move) + + +class FilterWheel(Axis): + """A configured rotary wheel with a learned physical-position-one datum.""" + + config: FilterWheelConfig + + def __init__( + self, + controller: MotorController, + component_name: str, + config: FilterWheelConfig, + ) -> None: + super().__init__(controller, component_name, config) + self._home_encoder_ticks: Optional[int] = None + + @property + def has_position_reference(self) -> bool: + """Whether this process has homed the wheel to physical position one.""" + return self._home_encoder_ticks is not None + + async def _initialize(self) -> None: + """Forget the learned wheel datum and replay the motor configuration.""" + self._home_encoder_ticks = None + await super()._initialize() + + def _ticks_per_position(self) -> int: + if self.config.number_of_filters <= 0 or self.config.encoder_ticks_per_revolution <= 0: + raise CeligoError(f"{self.name} wheel geometry is invalid") + if self.config.encoder_ticks_per_revolution % self.config.number_of_filters != 0: + raise CeligoError(f"{self.name} encoder ticks/revolution is not divisible by filter count") + return self.config.encoder_ticks_per_revolution // self.config.number_of_filters + + async def home(self) -> int: + """Reference the encoder index and locate physical wheel position one.""" + if not self.is_initialized: + await self._initialize() + ticks_per_position = self._ticks_per_position() + self._home_encoder_ticks = None + search_distance_ticks = round(ticks_per_position * 1.2) + index_velocity = self._rate_to_encoder_tick_rate(self.config.index_velocity) + index_mode = ( + _EZ_SPECIAL_ENCODER_WITH_INDEX_ACCURATE + if self._supports_accurate_encoder_index + else _EZ_SPECIAL_ENCODER_WITH_INDEX + ) + index_timeout = max( + self._controller.move_timeout, + abs(search_distance_ticks) / max(1, abs(index_velocity)) + 1.0, + ) + last_error: Optional[Exception] = None + for _ in range(3): + try: + await self._home_to_encoder_index( + search_distance_ticks, + index_velocity, + index_mode, + timeout=index_timeout, + ) + last_error = None + break + except (CeligoError, TimeoutError) as exc: + last_error = exc + if last_error is not None: + await self._initialize() + raise CeligoError(f"Failed to find encoder index for {self.name}") from last_error + + target_encoder_ticks = round(self.config.home_offset) + homing_velocity = self._rate_to_encoder_tick_rate(self.config.homing_velocity) + try: + for physical_position in range(1, self.config.number_of_filters + 1): + await self.move_to_ticks( + target_encoder_ticks, + velocity_ticks_per_second=homing_velocity, + ) + if await self.request_limit_flags() & _LIMIT_OPTO_1: + self._home_encoder_ticks = target_encoder_ticks + return target_encoder_ticks + if physical_position < self.config.number_of_filters: + target_encoder_ticks += ticks_per_position + except BaseException: + with contextlib.suppress(Exception): + await complete_cleanup(self._initialize()) + raise + await self._initialize() + raise CeligoError(f"Opto1 sensor was not active at any {self.name} position") + + async def move_to(self, logical_position: int) -> int: + """Move to a configured logical position by the shortest equivalent path.""" + if self._home_encoder_ticks is None: + raise CeligoError(f"{self.name} home position is unknown; home the wheel first") + logical_to_physical = { + entry.logical_number: entry.physical_number for entry in self.config.filter_map + } + try: + physical_position = logical_to_physical[logical_position] + except KeyError as exc: + raise CeligoError( + f"Logical position {logical_position} is not configured for {self.name}" + ) from exc + + ticks_per_position = self._ticks_per_position() + canonical_target = self._home_encoder_ticks + (physical_position - 1) * ticks_per_position + current_encoder_ticks = await self.request_encoder_ticks() + revolutions = math.ceil( + (current_encoder_ticks - canonical_target) / self.config.encoder_ticks_per_revolution - 0.5 + ) + return await self.move_to_ticks( + canonical_target + revolutions * self.config.encoder_ticks_per_revolution + ) + + +class MagnificationChanger(FilterWheel): + """Objective wheel that also owns the active magnification calibration state.""" + + def __init__( + self, + controller: MotorController, + config: FilterWheelConfig, + instrument_config: CeligoConfig, + ) -> None: + super().__init__(controller, "magnification", config) + self._instrument_config = instrument_config + + async def move_to(self, logical_position: int) -> int: + """Select a supported magnification and make its calibrations active.""" + if logical_position not in (3, 5, 10, 20): + raise CeligoError(f"Unsupported magnification {logical_position}X") + if logical_position not in self._instrument_config.channels_by_magnification: + raise CeligoError(f"No illumination-channel calibration is loaded for {logical_position}X") + settled_ticks = await super().move_to(logical_position) + self._instrument_config.magnification = logical_position + return settled_ticks diff --git a/pylabrobot/revvity/celigo/navigation.py b/pylabrobot/revvity/celigo/navigation.py new file mode 100644 index 00000000000..11ad6439214 --- /dev/null +++ b/pylabrobot/revvity/celigo/navigation.py @@ -0,0 +1,113 @@ +"""Plate / well navigation for the Celigo. + +Uses the coordinate systems from :mod:`pylabrobot.revvity.celigo.coordinates` to answer the +practical navigation questions the device asks: + +* where in stage millimeters is the center of well ``(row, col)``? +* within a stage position, what galvo FOV grid covers the scan area? + +The stage makes a coarse move to a Field-Of-Reference (FOR); the galvo sweeps a +serpentine grid of Fields-Of-View (FOV) within its deflection reach before the stage +must step. Effective FOV = frame size minus overlap; FOVs per FOR per axis = +``floor(2*MaxGalvoDeflection / EffectiveFOV)``. +""" + +from __future__ import annotations + +import math +from typing import List, Tuple + +from pylabrobot.resources.plate import Plate +from pylabrobot.revvity.celigo.config import ( + CalibrationConfig, + NavigationConfig, +) +from pylabrobot.revvity.celigo.coordinates import Coordinate2D, CoordinateSystems + + +def well_to_sample_mm(plate: Plate, well: str) -> Coordinate2D: + """Return a PLR well center in the Celigo's top-left plate coordinate frame.""" + if not isinstance(plate, Plate): + raise TypeError("plate must be a PyLabRobot Plate") + try: + item = plate.get_well(well.strip().upper()) + except (IndexError, ValueError) as exc: + raise ValueError(f"Well {well!r} does not exist on plate {plate.name!r}") from exc + if item.location is None: + raise ValueError(f"Well {well!r} on plate {plate.name!r} has no location") + return ( + item.location.x + item.get_size_x() / 2, + plate.get_size_y() - (item.location.y + item.get_size_y() / 2), + ) + + +def well_to_stage_mm( + plate: Plate, + well: str, + coordinate_systems: CoordinateSystems, +) -> Coordinate2D: + """Stage mm for the center of a named well (e.g. ``"A1"``).""" + sample_x_mm, sample_y_mm = well_to_sample_mm(plate, well) + return coordinate_systems.sample_mm_to_stage_mm(sample_x_mm, sample_y_mm) + + +def effective_fov_mm( + calibration: CalibrationConfig, + navigation: NavigationConfig, +) -> Coordinate2D: + """Frame size minus overlap, per axis (``EffectiveFOVMM``).""" + frame_x = calibration.image_width_pixels * calibration.microns_per_pixel_x / 1000.0 + frame_y = calibration.image_height_pixels * calibration.microns_per_pixel_y / 1000.0 + return ( + frame_x - 2 * navigation.frame_overlap_x_mm, + frame_y - 2 * navigation.frame_overlap_y_mm, + ) + + +def fields_of_view_per_field_of_reference( + calibration: CalibrationConfig, + navigation: NavigationConfig, +) -> "Tuple[int, int]": + """How many FOVs fit per FOR per axis within the galvo's reach.""" + effective_x_mm, effective_y_mm = effective_fov_mm(calibration, navigation) + columns = ( + math.floor(2 * navigation.max_galvo_deflection_x_mm / effective_x_mm) + if effective_x_mm > 0 + else 1 + ) + rows = ( + math.floor(2 * navigation.max_galvo_deflection_y_mm / effective_y_mm) + if effective_y_mm > 0 + else 1 + ) + return max(1, columns), max(1, rows) + + +def galvo_field_of_view_offsets_mm( + calibration: CalibrationConfig, + navigation: NavigationConfig, +) -> List[Coordinate2D]: + """Sample-relative FOV-center offsets (mm) in serpentine order. + + :meth:`pylabrobot.revvity.celigo.galvo.Galvo.voltages_for_offset` combines each offset with + the calibrated imaging center and logical-filter correction, including the conversion + to the galvo calibration frame. + """ + columns, rows = fields_of_view_per_field_of_reference(calibration, navigation) + effective_x_mm, effective_y_mm = effective_fov_mm(calibration, navigation) + # center the grid about (0, 0) + first_column = -(columns - 1) / 2.0 + first_row = -(rows - 1) / 2.0 + offsets: List[Coordinate2D] = [] + for row in range(rows): + column_indices = range(columns) if row % 2 == 0 else range(columns - 1, -1, -1) + offsets.extend( + ( + ( + (first_column + column) * effective_x_mm, + (first_row + row) * effective_y_mm, + ) + for column in column_indices + ) + ) + return offsets diff --git a/pylabrobot/revvity/celigo/protocol.py b/pylabrobot/revvity/celigo/protocol.py new file mode 100644 index 00000000000..b73247ffdef --- /dev/null +++ b/pylabrobot/revvity/celigo/protocol.py @@ -0,0 +1,33 @@ +"""Shared Celigo controller protocol helpers.""" + +import asyncio +import contextlib +from typing import Awaitable, TypeVar + +from pylabrobot.revvity.celigo.errors import CeligoError + +_T = TypeVar("_T") + + +def validate_payload_length( + payload: bytes, + minimum_byte_count: int, + operation: str, +) -> None: + """Reject a truncated controller payload before it is decoded.""" + if len(payload) < minimum_byte_count: + raise CeligoError( + f"Truncated {operation} response: expected at least {minimum_byte_count} payload bytes, " + f"got {len(payload)}" + ) + + +async def complete_cleanup(operation: Awaitable[_T]) -> _T: + """Finish a cleanup operation before propagating task cancellation.""" + task = asyncio.ensure_future(operation) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + with contextlib.suppress(Exception): + await task + raise diff --git a/pylabrobot/revvity/celigo/scan.py b/pylabrobot/revvity/celigo/scan.py new file mode 100644 index 00000000000..b753f1222f8 --- /dev/null +++ b/pylabrobot/revvity/celigo/scan.py @@ -0,0 +1,1240 @@ +"""Offline scan specifications, plans, and results for the Celigo.""" + +from __future__ import annotations + +import hashlib +import json +import math +import random +from dataclasses import asdict, dataclass, field +from datetime import timedelta +from typing import TYPE_CHECKING, List, Literal, Optional, Sequence, Tuple, Union + +from pylabrobot.resources.plate import Plate +from pylabrobot.revvity.celigo.camera import CameraFrame +from pylabrobot.revvity.celigo.config import CeligoConfig +from pylabrobot.revvity.celigo.coordinates import CoordinateSystems +from pylabrobot.revvity.celigo.navigation import effective_fov_mm, well_to_sample_mm + +if TYPE_CHECKING: + from pylabrobot.revvity.celigo.celigo import FocusResult + + +CoordinateMM = Tuple[float, float] +BlockShape = Tuple[int, int] +AutofocusMethod = Literal["image"] + +_EXACT_ROUTE_LIMIT = 14 + + +def _validate_finite(value: float, name: str) -> None: + if not math.isfinite(value): + raise ValueError(f"{name} must be finite") + + +def _validate_positive_integer(value: int, name: str) -> None: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"{name} must be a positive integer") + + +def _validate_block_shape(block_shape: BlockShape) -> BlockShape: + if not isinstance(block_shape, tuple) or len(block_shape) != 2: + raise ValueError("block_shape must be a (columns, rows) tuple") + columns, rows = block_shape + _validate_positive_integer(columns, "block columns") + _validate_positive_integer(rows, "block rows") + return columns, rows + + +def _validate_scan_region(region: "ScanRegion") -> "ScanRegion": + if not isinstance(region, ScanRegion): + raise TypeError("bounds must be a ScanRegion") + return region + + +def _validate_autofocus(autofocus: Optional[AutofocusMethod]) -> None: + if autofocus not in (None, "image"): + raise ValueError("autofocus must be None or 'image'") + + +def _configuration_fingerprint(config: CeligoConfig) -> str: + """Return a stable fingerprint of the configuration used to compile a scan plan.""" + serialized = json.dumps( + asdict(config), + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True) +class ScanRegion: + """Axis-aligned sample bounds in millimeters from the sample's top-left corner.""" + + left: float + top: float + right: float + bottom: float + + def __post_init__(self) -> None: + for value, name in ( + (self.left, "left"), + (self.top, "top"), + (self.right, "right"), + (self.bottom, "bottom"), + ): + _validate_finite(value, name) + if self.right <= self.left: + raise ValueError("right must be greater than left") + if self.bottom <= self.top: + raise ValueError("bottom must be greater than top") + + @classmethod + def from_bounds_mm( + cls, + *, + left: float, + top: float, + right: float, + bottom: float, + ) -> "ScanRegion": + """Create a sample-relative physical region.""" + return cls(left=left, top=top, right=right, bottom=bottom) + + @property + def width_mm(self) -> float: + return self.right - self.left + + @property + def height_mm(self) -> float: + return self.bottom - self.top + + @property + def area_mm2(self) -> float: + return self.width_mm * self.height_mm + + +@dataclass(frozen=True) +class ScanEstimateModel: + """Explicit throughput assumptions used for offline estimates.""" + + seconds_per_frame: float = 0.35 + seconds_per_stage_position: float = 2.0 + seconds_per_autofocus: float = 5.0 + bytes_per_pixel: int = 1 + + def __post_init__(self) -> None: + for value, name in ( + (self.seconds_per_frame, "seconds_per_frame"), + (self.seconds_per_stage_position, "seconds_per_stage_position"), + (self.seconds_per_autofocus, "seconds_per_autofocus"), + ): + _validate_finite(value, name) + if value < 0: + raise ValueError(f"{name} must be non-negative") + _validate_positive_integer(self.bytes_per_pixel, "bytes_per_pixel") + + +@dataclass(frozen=True) +class Capture: + """One channel and its camera settings.""" + + channel: str + exposure_ms: Optional[float] = None + gain: Optional[float] = None + + def __post_init__(self) -> None: + if not isinstance(self.channel, str) or not self.channel.strip(): + raise ValueError("channel must be a non-empty string") + object.__setattr__(self, "channel", self.channel.strip()) + if self.exposure_ms is not None: + _validate_finite(self.exposure_ms, "exposure_ms") + if self.exposure_ms <= 0: + raise ValueError("exposure_ms must be positive") + if self.gain is not None: + _validate_finite(self.gain, "gain") + if self.gain < 0: + raise ValueError("gain must be non-negative") + + +@dataclass(frozen=True) +class _PointGeometry: + centers_mm: Tuple[CoordinateMM, ...] + labels: Tuple[Optional[str], ...] + block_shape: BlockShape + + +@dataclass(frozen=True) +class _RandomGeometry: + bounds: ScanRegion + count: int + block_shape: BlockShape + seed: int + non_overlapping: bool + + +@dataclass(frozen=True) +class _FullCoverageGeometry: + bounds: ScanRegion + + +_SpecGeometry = Union[_PointGeometry, _RandomGeometry, _FullCoverageGeometry] + + +@dataclass(frozen=True) +class ScanSpec: + """A complete, reusable description of scan geometry and acquisition settings.""" + + geometry: _SpecGeometry + captures: Tuple[Capture, ...] + autofocus: Optional[AutofocusMethod] + + def __post_init__(self) -> None: + if not isinstance( + self.geometry, + (_PointGeometry, _RandomGeometry, _FullCoverageGeometry), + ): + raise TypeError("geometry must be created by a ScanSpec constructor") + if not self.captures or any(not isinstance(capture, Capture) for capture in self.captures): + raise ValueError("captures must contain at least one Capture") + _validate_autofocus(self.autofocus) + + @classmethod + def wells( + cls, + plate: Plate, + wells: Sequence[str], + *, + block_shape: BlockShape = (1, 1), + channel: Optional[str] = None, + exposure_ms: Optional[float] = None, + gain: Optional[float] = None, + captures: Optional[Sequence[Capture]] = None, + autofocus: Optional[AutofocusMethod] = None, + ) -> "ScanSpec": + """Create a scan at named well centers without retaining the plate.""" + if not isinstance(plate, Plate): + raise TypeError("plate must be a PyLabRobot Plate") + if isinstance(wells, str): + raise ValueError("wells must be a sequence of well names") + normalized_wells = tuple(well.strip().upper() for well in wells) + if not normalized_wells: + raise ValueError("wells must contain at least one well name") + if any(not well for well in normalized_wells): + raise ValueError("well names must not be empty") + centers = tuple(well_to_sample_mm(plate, well) for well in normalized_wells) + return cls( + geometry=_point_geometry(centers, block_shape, normalized_wells), + captures=_normalize_captures(channel, exposure_ms, gain, captures), + autofocus=autofocus, + ) + + @classmethod + def points( + cls, + centers_mm: Sequence[CoordinateMM], + *, + block_shape: BlockShape = (1, 1), + channel: Optional[str] = None, + exposure_ms: Optional[float] = None, + gain: Optional[float] = None, + captures: Optional[Sequence[Capture]] = None, + autofocus: Optional[AutofocusMethod] = None, + ) -> "ScanSpec": + """Create an anonymous scan centered at explicit sample-relative points.""" + normalized_centers = _normalize_centers(centers_mm) + return cls( + geometry=_point_geometry(normalized_centers, block_shape), + captures=_normalize_captures(channel, exposure_ms, gain, captures), + autofocus=autofocus, + ) + + @classmethod + def random( + cls, + bounds: ScanRegion, + *, + count: int, + block_shape: BlockShape, + seed: int = 0, + non_overlapping: bool = True, + channel: Optional[str] = None, + exposure_ms: Optional[float] = None, + gain: Optional[float] = None, + captures: Optional[Sequence[Capture]] = None, + autofocus: Optional[AutofocusMethod] = None, + ) -> "ScanSpec": + """Create a reproducible random sample of blocks within physical bounds.""" + _validate_scan_region(bounds) + _validate_positive_integer(count, "count") + validated_shape = _validate_block_shape(block_shape) + if isinstance(seed, bool) or not isinstance(seed, int): + raise ValueError("seed must be an integer") + if not isinstance(non_overlapping, bool): + raise ValueError("non_overlapping must be a boolean") + return cls( + geometry=_RandomGeometry( + bounds=bounds, + count=count, + block_shape=validated_shape, + seed=seed, + non_overlapping=non_overlapping, + ), + captures=_normalize_captures(channel, exposure_ms, gain, captures), + autofocus=autofocus, + ) + + @classmethod + def full_coverage( + cls, + bounds: ScanRegion, + *, + channel: Optional[str] = None, + exposure_ms: Optional[float] = None, + gain: Optional[float] = None, + captures: Optional[Sequence[Capture]] = None, + autofocus: Optional[AutofocusMethod] = None, + ) -> "ScanSpec": + """Create a scan that covers all physical bounds.""" + _validate_scan_region(bounds) + return cls( + geometry=_FullCoverageGeometry(bounds=bounds), + captures=_normalize_captures(channel, exposure_ms, gain, captures), + autofocus=autofocus, + ) + + +@dataclass(frozen=True) +class ScanPosition: + """One camera frame target relative to a coarse-stage block.""" + + index: int + block_index: int + tile_row: int + tile_column: int + sample_x_mm: float + sample_y_mm: float + galvo_offset_x_mm: float + galvo_offset_y_mm: float + + +@dataclass(frozen=True) +class ScanBlock: + """One stationary coarse-stage position and the frames acquired there.""" + + index: int + center_x_mm: float + center_y_mm: float + stage_x_mm: float + stage_y_mm: float + bounds: ScanRegion + block_shape: BlockShape + label: Optional[str] = None + + @property + def tile_columns(self) -> int: + return self.block_shape[0] + + @property + def tile_rows(self) -> int: + return self.block_shape[1] + + +@dataclass(frozen=True) +class PlannedFrame: + """One exact frame operation in a compiled scan plan.""" + + index: int + block: ScanBlock + position: ScanPosition + capture: Capture + + +@dataclass(frozen=True) +class ScanPlan: + """An inspectable, hardware-free sequence of validated frame operations.""" + + spec: ScanSpec + configuration_fingerprint: str = field(repr=False) + blocks: Tuple[ScanBlock, ...] + frames: Tuple[PlannedFrame, ...] + estimate_model: ScanEstimateModel + frame_count: int + stage_position_count: int + autofocus_count: int + sampled_area_mm2: float + estimated_duration: timedelta + estimated_storage_bytes: int + + @property + def positions(self) -> Tuple[ScanPosition, ...]: + return tuple(frame.position for frame in self.frames) + + @property + def stage_positions_mm(self) -> Tuple[CoordinateMM, ...]: + return tuple((block.stage_x_mm, block.stage_y_mm) for block in self.blocks) + + def matches_configuration(self, config: CeligoConfig) -> bool: + """Whether this plan was compiled from the supplied configuration state.""" + return self.configuration_fingerprint == _configuration_fingerprint(config) + + def __str__(self) -> str: + channels = ", ".join(capture.channel for capture in self.spec.captures) + return ( + "ScanPlan\n" + f" geometry: {_describe_geometry(self.spec.geometry)}\n" + f" channels: {channels}\n" + f" blocks: {len(self.blocks)}\n" + f" frames: {self.frame_count}\n" + f" stage positions: {self.stage_position_count}\n" + f" autofocus operations: {self.autofocus_count}\n" + f" estimated duration: {_format_duration(self.estimated_duration)}\n" + f" estimated storage: {_format_bytes(self.estimated_storage_bytes)}\n" + f" sampled area: {self.sampled_area_mm2:.3f} mm²" + ) + + +@dataclass(frozen=True) +class FrameResult: + """A captured frame linked to the exact operation that produced it.""" + + planned: PlannedFrame + frame: CameraFrame + actual_stage_mm: CoordinateMM + actual_z_mm: float + galvo_hardware_voltages: Tuple[float, float] + focus: Optional["FocusResult"] + + +@dataclass(frozen=True) +class ScanResult: + """All frames and elapsed time from executing one plan.""" + + plan: ScanPlan + frames: Tuple[FrameResult, ...] + elapsed: timedelta + + +@dataclass(frozen=True) +class _CaptureGeometry: + frame_x_mm: float + frame_y_mm: float + step_x_mm: float + step_y_mm: float + max_block_columns: int + max_block_rows: int + + def block_size_mm(self, block_shape: BlockShape) -> CoordinateMM: + columns, rows = _validate_block_shape(block_shape) + return ( + self.frame_x_mm + (columns - 1) * self.step_x_mm, + self.frame_y_mm + (rows - 1) * self.step_y_mm, + ) + + +@dataclass(frozen=True) +class _BlockSelection: + x_centers_mm: Tuple[float, ...] + y_centers_mm: Tuple[float, ...] + + +@dataclass(frozen=True) +class _BlockLayout: + selections: Tuple[_BlockSelection, ...] + label: Optional[str] + bounds: Optional[ScanRegion] + + +def build_scan_plan( + config: CeligoConfig, + spec: ScanSpec, + *, + estimate_model: Optional[ScanEstimateModel] = None, +) -> ScanPlan: + """Compile a complete scan specification without communicating with hardware.""" + if not isinstance(config, CeligoConfig): + raise TypeError("config must be a CeligoConfig") + if not isinstance(spec, ScanSpec): + raise TypeError("spec must be a ScanSpec") + estimates = ScanEstimateModel() if estimate_model is None else estimate_model + if not isinstance(estimates, ScanEstimateModel): + raise TypeError("estimate_model must be a ScanEstimateModel") + + capture_geometries = tuple(_capture_geometry(config, capture) for capture in spec.captures) + layouts = _build_layouts(spec.geometry, capture_geometries) + coordinate_systems = CoordinateSystems.from_config( + config.calibration, + config.hardware_defaults, + ) + + blocks: List[ScanBlock] = [] + frames: List[PlannedFrame] = [] + footprints: List[ScanRegion] = [] + next_position_index = 0 + for block_index, layout in enumerate(layouts): + block, positions, block_footprints, next_position_index = _compile_block( + config=config, + coordinate_systems=coordinate_systems, + captures=spec.captures, + capture_geometries=capture_geometries, + block_index=block_index, + first_position_index=next_position_index, + layout=layout, + ) + blocks.append(block) + footprints.extend(block_footprints) + for position, capture in positions: + frames.append( + PlannedFrame( + index=len(frames), + block=block, + position=position, + capture=capture, + ) + ) + + frame_count = len(frames) + stage_position_count = len(blocks) + autofocus_count = stage_position_count if spec.autofocus is not None else 0 + exposure_seconds = sum( + 0.0 if frame.capture.exposure_ms is None else frame.capture.exposure_ms / 1000.0 + for frame in frames + ) + duration_seconds = ( + frame_count * estimates.seconds_per_frame + + exposure_seconds + + stage_position_count * estimates.seconds_per_stage_position + + autofocus_count * estimates.seconds_per_autofocus + ) + calibration = config.calibration + storage_bytes = ( + frame_count + * calibration.image_width_pixels + * calibration.image_height_pixels + * estimates.bytes_per_pixel + ) + return ScanPlan( + spec=spec, + configuration_fingerprint=_configuration_fingerprint(config), + blocks=tuple(blocks), + frames=tuple(frames), + estimate_model=estimates, + frame_count=frame_count, + stage_position_count=stage_position_count, + autofocus_count=autofocus_count, + sampled_area_mm2=_union_area(footprints), + estimated_duration=timedelta(seconds=duration_seconds), + estimated_storage_bytes=storage_bytes, + ) + + +def _normalize_captures( + channel: Optional[str], + exposure_ms: Optional[float], + gain: Optional[float], + captures: Optional[Sequence[Capture]], +) -> Tuple[Capture, ...]: + if captures is not None: + if channel is not None or exposure_ms is not None or gain is not None: + raise ValueError("captures cannot be combined with channel, exposure_ms, or gain") + normalized = tuple(captures) + if not normalized or any(not isinstance(capture, Capture) for capture in normalized): + raise ValueError("captures must contain at least one Capture") + return normalized + if channel is None: + raise ValueError("provide channel or captures") + return (Capture(channel=channel, exposure_ms=exposure_ms, gain=gain),) + + +def _normalize_centers(centers_mm: Sequence[CoordinateMM]) -> Tuple[CoordinateMM, ...]: + if isinstance(centers_mm, (str, bytes)): + raise ValueError("centers_mm must be a sequence of (x, y) coordinates") + try: + centers = tuple((float(x), float(y)) for x, y in centers_mm) + except (TypeError, ValueError) as exc: + raise ValueError("centers_mm must contain (x, y) coordinates") from exc + if not centers: + raise ValueError("centers_mm must contain at least one coordinate") + for index, (x_mm, y_mm) in enumerate(centers): + _validate_finite(x_mm, f"centers_mm[{index}].x") + _validate_finite(y_mm, f"centers_mm[{index}].y") + return centers + + +def _point_geometry( + centers_mm: Sequence[CoordinateMM], + block_shape: BlockShape, + labels: Optional[Sequence[str]] = None, +) -> _PointGeometry: + centers = _normalize_centers(centers_mm) + shape = _validate_block_shape(block_shape) + if labels is None: + normalized_labels: Tuple[Optional[str], ...] = (None,) * len(centers) + else: + values = tuple(labels) + if len(values) != len(centers): + raise ValueError("labels must have the same length as centers_mm") + if any(not isinstance(label, str) or not label for label in values): + raise ValueError("labels must contain non-empty strings") + normalized_labels = tuple(values) + return _PointGeometry( + centers_mm=centers, + labels=normalized_labels, + block_shape=shape, + ) + + +def _capture_geometry(config: CeligoConfig, capture: Capture) -> _CaptureGeometry: + try: + channel = config.channels[capture.channel] + except KeyError as exc: + available = ", ".join(sorted(config.channels)) or "none" + raise ValueError( + f"Unknown channel {capture.channel!r}; available channels: {available}" + ) from exc + + x_correction = channel.mm_per_pixel_x_correction_to_brightfield + y_correction = channel.mm_per_pixel_y_correction_to_brightfield + for value, name in ( + (x_correction, f"{capture.channel} X pixel-scale correction"), + (y_correction, f"{capture.channel} Y pixel-scale correction"), + ): + _validate_finite(value, name) + if value <= 0: + raise ValueError(f"{name} must be positive") + + calibration = config.calibration + base_step_x_mm, base_step_y_mm = effective_fov_mm( + calibration, + config.navigation, + ) + for value, name in ( + (base_step_x_mm, "frame X step"), + (base_step_y_mm, "frame Y step"), + ): + _validate_finite(value, name) + if value <= 0: + raise ValueError(f"{name} must be positive") + geometry = _CaptureGeometry( + frame_x_mm=( + calibration.image_width_pixels * calibration.microns_per_pixel_x / 1000.0 * x_correction + ), + frame_y_mm=( + calibration.image_height_pixels * calibration.microns_per_pixel_y / 1000.0 * y_correction + ), + step_x_mm=base_step_x_mm * x_correction, + step_y_mm=base_step_y_mm * y_correction, + max_block_columns=max( + 1, + math.floor(2 * config.navigation.max_galvo_deflection_x_mm / (base_step_x_mm * x_correction)), + ), + max_block_rows=max( + 1, + math.floor(2 * config.navigation.max_galvo_deflection_y_mm / (base_step_y_mm * y_correction)), + ), + ) + for value, name in ( + (geometry.frame_x_mm, "frame width"), + (geometry.frame_y_mm, "frame height"), + (geometry.step_x_mm, "frame X step"), + (geometry.step_y_mm, "frame Y step"), + ): + _validate_finite(value, name) + if value <= 0: + raise ValueError(f"{name} must be positive") + return geometry + + +def _build_layouts( + spec_geometry: _SpecGeometry, + capture_geometries: Tuple[_CaptureGeometry, ...], +) -> List[_BlockLayout]: + if isinstance(spec_geometry, _PointGeometry): + return _point_layouts(spec_geometry, capture_geometries) + if isinstance(spec_geometry, _RandomGeometry): + return _random_layouts(spec_geometry, capture_geometries) + return _full_coverage_layouts(spec_geometry, capture_geometries) + + +def _point_layouts( + geometry: _PointGeometry, + capture_geometries: Tuple[_CaptureGeometry, ...], +) -> List[_BlockLayout]: + _validate_shape_for_captures(geometry.block_shape, capture_geometries) + return [ + _BlockLayout( + selections=tuple( + _selection_from_center(x_mm, y_mm, geometry.block_shape, capture_geometry) + for capture_geometry in capture_geometries + ), + label=geometry.labels[index], + bounds=None, + ) + for index, (x_mm, y_mm) in enumerate(geometry.centers_mm) + ] + + +def _random_layouts( + geometry: _RandomGeometry, + capture_geometries: Tuple[_CaptureGeometry, ...], +) -> List[_BlockLayout]: + _validate_shape_for_captures(geometry.block_shape, capture_geometries) + block_width_mm = max(item.block_size_mm(geometry.block_shape)[0] for item in capture_geometries) + block_height_mm = max(item.block_size_mm(geometry.block_shape)[1] for item in capture_geometries) + if geometry.non_overlapping: + x_candidates = _non_overlapping_block_centers( + geometry.bounds.left, + geometry.bounds.right, + block_width_mm, + ) + y_candidates = _non_overlapping_block_centers( + geometry.bounds.top, + geometry.bounds.bottom, + block_height_mm, + ) + else: + x_candidates = _candidate_block_centers( + geometry.bounds.left, + geometry.bounds.right, + block_width_mm, + min(item.step_x_mm for item in capture_geometries), + ) + y_candidates = _candidate_block_centers( + geometry.bounds.top, + geometry.bounds.bottom, + block_height_mm, + min(item.step_y_mm for item in capture_geometries), + ) + candidates = [(x_mm, y_mm) for y_mm in y_candidates for x_mm in x_candidates] + if geometry.count > len(candidates): + qualifier = " non-overlapping" if geometry.non_overlapping else "" + raise ValueError( + f"Requested {geometry.count}{qualifier} blocks, but only {len(candidates)} " + "fit within the scan bounds" + ) + selected = random.Random(geometry.seed).sample(candidates, geometry.count) + ordered = _shortest_travel_order(selected) + return [ + _BlockLayout( + selections=tuple( + _selection_from_center(x_mm, y_mm, geometry.block_shape, capture_geometry) + for capture_geometry in capture_geometries + ), + label=None, + bounds=geometry.bounds, + ) + for x_mm, y_mm in ordered + ] + + +def _shortest_travel_order(points: Sequence[CoordinateMM]) -> List[CoordinateMM]: + """Order selected points along a short open path without changing the selection.""" + if len(points) <= 1: + return list(points) + distances = [[math.dist(start, end) for end in points] for start in points] + if len(points) <= _EXACT_ROUTE_LIMIT: + order = _exact_open_path(distances) + else: + order = _heuristic_open_path(points, distances) + return [points[index] for index in order] + + +def _exact_open_path(distances: Sequence[Sequence[float]]) -> List[int]: + point_count = len(distances) + state_count = 1 << point_count + costs = [[math.inf] * point_count for _ in range(state_count)] + parents = [[-1] * point_count for _ in range(state_count)] + for index in range(point_count): + costs[1 << index][index] = 0.0 + + for mask in range(1, state_count): + for end in range(point_count): + end_bit = 1 << end + if mask & end_bit == 0: + continue + previous_mask = mask ^ end_bit + if previous_mask == 0: + continue + best_cost = math.inf + best_previous = -1 + for previous in range(point_count): + if previous_mask & (1 << previous) == 0: + continue + cost = costs[previous_mask][previous] + distances[previous][end] + if cost < best_cost: + best_cost = cost + best_previous = previous + costs[mask][end] = best_cost + parents[mask][end] = best_previous + + mask = state_count - 1 + end = min(range(point_count), key=lambda index: (costs[mask][index], index)) + reversed_order = [] + while end >= 0: + reversed_order.append(end) + previous = parents[mask][end] + mask ^= 1 << end + end = previous + return list(reversed(reversed_order)) + + +def _heuristic_open_path( + points: Sequence[CoordinateMM], + distances: Sequence[Sequence[float]], +) -> List[int]: + point_count = len(points) + starts = { + 0, + min(range(point_count), key=lambda index: points[index][0]), + max(range(point_count), key=lambda index: points[index][0]), + min(range(point_count), key=lambda index: points[index][1]), + max(range(point_count), key=lambda index: points[index][1]), + } + routes = [_nearest_neighbor_path(start, distances) for start in starts] + order = min(routes, key=lambda route: _path_length(route, distances)) + return _improve_open_path(order, distances) + + +def _nearest_neighbor_path( + start: int, + distances: Sequence[Sequence[float]], +) -> List[int]: + unvisited = set(range(len(distances))) + unvisited.remove(start) + order = [start] + while unvisited: + current = order[-1] + next_index = min(unvisited, key=lambda index: (distances[current][index], index)) + order.append(next_index) + unvisited.remove(next_index) + return order + + +def _improve_open_path( + order: List[int], + distances: Sequence[Sequence[float]], +) -> List[int]: + improved = True + while improved: + improved = False + for start in range(len(order) - 1): + for end in range(start + 1, len(order)): + current = 0.0 + replacement = 0.0 + if start > 0: + current += distances[order[start - 1]][order[start]] + replacement += distances[order[start - 1]][order[end]] + if end + 1 < len(order): + current += distances[order[end]][order[end + 1]] + replacement += distances[order[start]][order[end + 1]] + if replacement + 1e-12 < current: + order[start : end + 1] = reversed(order[start : end + 1]) + improved = True + return order + + +def _path_length( + order: Sequence[int], + distances: Sequence[Sequence[float]], +) -> float: + return sum(distances[start][end] for start, end in zip(order, order[1:])) + + +def _full_coverage_layouts( + geometry: _FullCoverageGeometry, + capture_geometries: Tuple[_CaptureGeometry, ...], +) -> List[_BlockLayout]: + x_counts = [ + len( + _coverage_axis_centers( + geometry.bounds.left, + geometry.bounds.right, + item.frame_x_mm, + item.step_x_mm, + ) + ) + for item in capture_geometries + ] + y_counts = [ + len( + _coverage_axis_centers( + geometry.bounds.top, + geometry.bounds.bottom, + item.frame_y_mm, + item.step_y_mm, + ) + ) + for item in capture_geometries + ] + column_count = max(x_counts) + row_count = max(y_counts) + x_centers_by_capture = tuple( + _coverage_axis_centers_with_count( + geometry.bounds.left, + geometry.bounds.right, + item.frame_x_mm, + column_count, + ) + for item in capture_geometries + ) + y_centers_by_capture = tuple( + _coverage_axis_centers_with_count( + geometry.bounds.top, + geometry.bounds.bottom, + item.frame_y_mm, + row_count, + ) + for item in capture_geometries + ) + + columns_per_block = min(item.max_block_columns for item in capture_geometries) + rows_per_block = min(item.max_block_rows for item in capture_geometries) + x_groups = _chunks(tuple(range(column_count)), columns_per_block) + y_groups = _chunks(tuple(range(row_count)), rows_per_block) + layouts: List[_BlockLayout] = [] + for block_row, y_indices in enumerate(y_groups): + x_group_indices: Sequence[int] + if block_row % 2 == 0: + x_group_indices = range(len(x_groups)) + else: + x_group_indices = range(len(x_groups) - 1, -1, -1) + for x_group_index in x_group_indices: + x_indices = x_groups[x_group_index] + layouts.append( + _BlockLayout( + selections=tuple( + _BlockSelection( + x_centers_mm=tuple(x_centers_by_capture[capture_index][index] for index in x_indices), + y_centers_mm=tuple(y_centers_by_capture[capture_index][index] for index in y_indices), + ) + for capture_index in range(len(capture_geometries)) + ), + label=None, + bounds=geometry.bounds, + ) + ) + return layouts + + +def _validate_shape_for_captures( + block_shape: BlockShape, + capture_geometries: Sequence[_CaptureGeometry], +) -> None: + columns, rows = _validate_block_shape(block_shape) + for geometry in capture_geometries: + if columns > geometry.max_block_columns or rows > geometry.max_block_rows: + raise ValueError( + f"Requested {columns}x{rows} block_shape, but the calibrated galvo limit is " + f"{geometry.max_block_columns}x{geometry.max_block_rows}" + ) + + +def _compile_block( + *, + config: CeligoConfig, + coordinate_systems: CoordinateSystems, + captures: Tuple[Capture, ...], + capture_geometries: Tuple[_CaptureGeometry, ...], + block_index: int, + first_position_index: int, + layout: _BlockLayout, +) -> Tuple[ + ScanBlock, + List[Tuple[ScanPosition, Capture]], + List[ScanRegion], + int, +]: + selection_bounds = tuple( + _selection_bounds(selection, geometry) + for selection, geometry in zip(layout.selections, capture_geometries) + ) + if layout.bounds is not None: + for bounds in selection_bounds: + if not _contains(layout.bounds, bounds): + raise ValueError(f"Planned block {block_index} extends outside the scan bounds") + + all_x_centers = [center for selection in layout.selections for center in selection.x_centers_mm] + all_y_centers = [center for selection in layout.selections for center in selection.y_centers_mm] + center_x_mm = (min(all_x_centers) + max(all_x_centers)) / 2.0 + center_y_mm = (min(all_y_centers) + max(all_y_centers)) / 2.0 + stage_x_mm, stage_y_mm = coordinate_systems.sample_mm_to_stage_mm( + center_x_mm, + center_y_mm, + ) + _validate_stage_target(config, block_index, stage_x_mm, stage_y_mm) + + combined_bounds = ScanRegion( + left=min(bounds.left for bounds in selection_bounds), + top=min(bounds.top for bounds in selection_bounds), + right=max(bounds.right for bounds in selection_bounds), + bottom=max(bounds.bottom for bounds in selection_bounds), + ) + first_selection = layout.selections[0] + block = ScanBlock( + index=block_index, + center_x_mm=center_x_mm, + center_y_mm=center_y_mm, + stage_x_mm=stage_x_mm, + stage_y_mm=stage_y_mm, + bounds=combined_bounds, + block_shape=( + len(first_selection.x_centers_mm), + len(first_selection.y_centers_mm), + ), + label=layout.label, + ) + + positions: List[Tuple[ScanPosition, Capture]] = [] + footprints: List[ScanRegion] = [] + position_index = first_position_index + for capture, capture_geometry, selection in zip( + captures, + capture_geometries, + layout.selections, + ): + for tile_row, sample_y_mm in enumerate(selection.y_centers_mm): + columns: Sequence[int] + if tile_row % 2 == 0: + columns = range(len(selection.x_centers_mm)) + else: + columns = range(len(selection.x_centers_mm) - 1, -1, -1) + for tile_column in columns: + sample_x_mm = selection.x_centers_mm[tile_column] + offset_x_mm = sample_x_mm - center_x_mm + offset_y_mm = sample_y_mm - center_y_mm + if abs(offset_x_mm) > config.navigation.max_galvo_deflection_x_mm + 1e-9: + raise ValueError(f"Block {block_index} exceeds calibrated X galvo reach") + if abs(offset_y_mm) > config.navigation.max_galvo_deflection_y_mm + 1e-9: + raise ValueError(f"Block {block_index} exceeds calibrated Y galvo reach") + position = ScanPosition( + index=position_index, + block_index=block_index, + tile_row=tile_row, + tile_column=tile_column, + sample_x_mm=sample_x_mm, + sample_y_mm=sample_y_mm, + galvo_offset_x_mm=offset_x_mm, + galvo_offset_y_mm=offset_y_mm, + ) + positions.append((position, capture)) + footprints.append( + ScanRegion( + left=sample_x_mm - capture_geometry.frame_x_mm / 2.0, + top=sample_y_mm - capture_geometry.frame_y_mm / 2.0, + right=sample_x_mm + capture_geometry.frame_x_mm / 2.0, + bottom=sample_y_mm + capture_geometry.frame_y_mm / 2.0, + ) + ) + position_index += 1 + return block, positions, footprints, position_index + + +def _validate_stage_target( + config: CeligoConfig, + block_index: int, + stage_x_mm: float, + stage_y_mm: float, +) -> None: + x_axis = config.hardware.x_axis + y_axis = config.hardware.y_axis + if x_axis is not None and not x_axis.min_position <= stage_x_mm <= x_axis.max_position: + raise ValueError( + f"Block {block_index} X stage target {stage_x_mm:g} mm is outside " + f"{x_axis.min_position:g}..{x_axis.max_position:g} mm" + ) + if y_axis is not None and not y_axis.min_position <= stage_y_mm <= y_axis.max_position: + raise ValueError( + f"Block {block_index} Y stage target {stage_y_mm:g} mm is outside " + f"{y_axis.min_position:g}..{y_axis.max_position:g} mm" + ) + + +def _selection_from_center( + center_x_mm: float, + center_y_mm: float, + block_shape: BlockShape, + geometry: _CaptureGeometry, +) -> _BlockSelection: + columns, rows = block_shape + first_x_mm = center_x_mm - (columns - 1) * geometry.step_x_mm / 2.0 + first_y_mm = center_y_mm - (rows - 1) * geometry.step_y_mm / 2.0 + return _BlockSelection( + x_centers_mm=tuple(first_x_mm + index * geometry.step_x_mm for index in range(columns)), + y_centers_mm=tuple(first_y_mm + index * geometry.step_y_mm for index in range(rows)), + ) + + +def _selection_bounds( + selection: _BlockSelection, + geometry: _CaptureGeometry, +) -> ScanRegion: + return ScanRegion( + left=selection.x_centers_mm[0] - geometry.frame_x_mm / 2.0, + top=selection.y_centers_mm[0] - geometry.frame_y_mm / 2.0, + right=selection.x_centers_mm[-1] + geometry.frame_x_mm / 2.0, + bottom=selection.y_centers_mm[-1] + geometry.frame_y_mm / 2.0, + ) + + +def _coverage_axis_centers( + start_mm: float, + end_mm: float, + frame_mm: float, + maximum_step_mm: float, +) -> Tuple[float, ...]: + minimum_center = start_mm + frame_mm / 2.0 + maximum_center = end_mm - frame_mm / 2.0 + center_span = maximum_center - minimum_center + if center_span < -1e-9: + raise ValueError( + f"Scan bound length {end_mm - start_mm:g} mm is smaller than the {frame_mm:g} mm camera frame" + ) + if center_span <= 1e-9: + return ((start_mm + end_mm) / 2.0,) + interval_count = max(1, math.ceil(center_span / maximum_step_mm - 1e-12)) + return _coverage_axis_centers_with_count( + start_mm, + end_mm, + frame_mm, + interval_count + 1, + ) + + +def _coverage_axis_centers_with_count( + start_mm: float, + end_mm: float, + frame_mm: float, + count: int, +) -> Tuple[float, ...]: + minimum_center = start_mm + frame_mm / 2.0 + maximum_center = end_mm - frame_mm / 2.0 + if maximum_center < minimum_center - 1e-9: + raise ValueError( + f"Scan bound length {end_mm - start_mm:g} mm is smaller than the {frame_mm:g} mm camera frame" + ) + if count == 1: + return ((start_mm + end_mm) / 2.0,) + step_mm = (maximum_center - minimum_center) / (count - 1) + centers = [minimum_center + index * step_mm for index in range(count)] + centers[-1] = maximum_center + return tuple(centers) + + +def _chunks(values: Tuple[int, ...], size: int) -> List[Tuple[int, ...]]: + return [values[start : start + size] for start in range(0, len(values), size)] + + +def _non_overlapping_block_centers( + start_mm: float, + end_mm: float, + block_mm: float, +) -> Tuple[float, ...]: + available_mm = end_mm - start_mm + if block_mm > available_mm + 1e-9: + raise ValueError( + f"Imaging block length {block_mm:g} mm does not fit within scan bound " + f"length {available_mm:g} mm" + ) + count = max(1, math.floor(available_mm / block_mm + 1e-12)) + occupied_mm = count * block_mm + first_center_mm = start_mm + (available_mm - occupied_mm) / 2.0 + block_mm / 2.0 + return tuple(first_center_mm + index * block_mm for index in range(count)) + + +def _candidate_block_centers( + start_mm: float, + end_mm: float, + block_mm: float, + step_mm: float, +) -> Tuple[float, ...]: + minimum_center = start_mm + block_mm / 2.0 + maximum_center = end_mm - block_mm / 2.0 + if maximum_center < minimum_center - 1e-9: + raise ValueError( + f"Imaging block length {block_mm:g} mm does not fit within scan bound " + f"length {end_mm - start_mm:g} mm" + ) + if maximum_center <= minimum_center + 1e-9: + return ((start_mm + end_mm) / 2.0,) + count = math.floor((maximum_center - minimum_center) / step_mm + 1e-12) + 1 + candidates = [minimum_center + index * step_mm for index in range(count)] + if maximum_center - candidates[-1] > 1e-9: + candidates.append(maximum_center) + return tuple(candidates) + + +def _contains(outer: ScanRegion, inner: ScanRegion) -> bool: + tolerance = 1e-9 + return ( + inner.left >= outer.left - tolerance + and inner.top >= outer.top - tolerance + and inner.right <= outer.right + tolerance + and inner.bottom <= outer.bottom + tolerance + ) + + +def _union_area(regions: Sequence[ScanRegion]) -> float: + if not regions: + return 0.0 + x_edges = sorted({edge for region in regions for edge in (region.left, region.right)}) + area = 0.0 + for left, right in zip(x_edges, x_edges[1:]): + intervals = sorted( + (region.top, region.bottom) + for region in regions + if region.left < right and region.right > left + ) + covered_y = 0.0 + if intervals: + current_top, current_bottom = intervals[0] + for top, bottom in intervals[1:]: + if top <= current_bottom: + current_bottom = max(current_bottom, bottom) + else: + covered_y += current_bottom - current_top + current_top, current_bottom = top, bottom + covered_y += current_bottom - current_top + area += (right - left) * covered_y + return area + + +def _describe_geometry(geometry: _SpecGeometry) -> str: + if isinstance(geometry, _PointGeometry): + columns, rows = geometry.block_shape + kind = "wells" if any(label is not None for label in geometry.labels) else "points" + return f"{kind}(count={len(geometry.centers_mm)}, block_shape={columns}x{rows})" + if isinstance(geometry, _RandomGeometry): + columns, rows = geometry.block_shape + return ( + f"random(count={geometry.count}, block_shape={columns}x{rows}, " + f"seed={geometry.seed}, non_overlapping={geometry.non_overlapping})" + ) + return ( + f"full_coverage(X={geometry.bounds.left:g}..{geometry.bounds.right:g} mm, " + f"Y={geometry.bounds.top:g}..{geometry.bounds.bottom:g} mm)" + ) + + +def _format_duration(duration: timedelta) -> str: + seconds = max(0, round(duration.total_seconds())) + hours, remainder = divmod(seconds, 3600) + minutes, seconds = divmod(remainder, 60) + parts = [] + if hours: + parts.append(f"{hours}h") + if minutes or hours: + parts.append(f"{minutes}m") + parts.append(f"{seconds}s") + return " ".join(parts) + + +def _format_bytes(byte_count: int) -> str: + units = ("B", "kB", "MB", "GB", "TB") + value = float(byte_count) + unit = units[0] + for unit in units: + if value < 1000.0 or unit == units[-1]: + break + value /= 1000.0 + return f"{value:.3g} {unit}" diff --git a/pylabrobot/revvity/celigo/tests/__init__.py b/pylabrobot/revvity/celigo/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/revvity/celigo/tests/config_tests.py b/pylabrobot/revvity/celigo/tests/config_tests.py new file mode 100644 index 00000000000..bfb94187aad --- /dev/null +++ b/pylabrobot/revvity/celigo/tests/config_tests.py @@ -0,0 +1,418 @@ +"""Tests for the Celigo hardware-config XML loader.""" + +import os +import tempfile +import unittest +from unittest.mock import patch + +from pylabrobot.revvity.celigo.config import ( + CeligoConfig, + CeligoHardwareConfig, + load_galvo_calibrations, + load_galvo_optical_calibration, + load_illumination_channels, +) +from pylabrobot.revvity.celigo.tests.helpers import make_linear_axis_config + +# A trimmed but structurally faithful USBIOHardwareConfig.config. +COMMON_MOTOR_XML = """ + 1001 + 0 + 1 + 0 + true + 45 + 45 + 45 + 0 + 20 + 10 + 0 + 0 + false + Normal_Accurate + 20 + 10 + 100 + 0 + true + true + false + true + 65 + 20 + 55 + 500 + true + false + true + false + 100 + 10 + 1 + 256 + 0 + 1 +""" + +SAMPLE_XML = f""" + + + + + {COMMON_MOTOR_XML} + X Axis + 1 + -18 + 0 + 100 + 0.0127 + 123 + + + {COMMON_MOTOR_XML} + Y Axis + 2 + 0 + 100 + 0.0127 + + + {COMMON_MOTOR_XML} + Z Axis + 3 + 0 + 14.5 + 0.001 + + + {COMMON_MOTOR_XML} + Dichroic + 4 + 0 + 6000 + 6 + 13 + 25 + + + + 10000 + 0truefalse + Brightfield05 + 0 + + + 10000 + 1truefalse + Green 483/53605 + 0 + + + 10000 + 2truefalseHWAF + + + + + +""" + + +class TestConfigFromXml(unittest.TestCase): + def setUp(self): + fd, self.path = tempfile.mkstemp(suffix=".config") + with os.fdopen(fd, "w") as f: + f.write(SAMPLE_XML) + + def tearDown(self): + os.remove(self.path) + + def test_axes_parsed(self): + cfg = CeligoHardwareConfig.from_xml(self.path) + x_axis = cfg.x_axis + assert x_axis is not None + self.assertEqual(x_axis.motion_name, "X Axis") + self.assertEqual(x_axis.axis_index, 1) + self.assertEqual(x_axis.comm_index, 1) + self.assertEqual(x_axis.home_offset, -18.0) + self.assertEqual(x_axis.home_type, "Normal_Accurate") + self.assertTrue(x_axis.enabled) + self.assertTrue(x_axis.mode_enable_position_correction) + self.assertEqual(x_axis.encoder_to_motor_tick_ratio, 256.0) + + def test_type_coercion(self): + cfg = CeligoHardwareConfig.from_xml(self.path) + x_axis = cfg.x_axis + assert x_axis is not None + self.assertIsInstance(x_axis.max_velocity, float) + self.assertIsInstance(x_axis.axis_index, int) + self.assertIsInstance(x_axis.enabled, bool) + + def test_unknown_field_kept_in_extra(self): + cfg = CeligoHardwareConfig.from_xml(self.path) + x_axis = cfg.x_axis + assert x_axis is not None + self.assertEqual(x_axis.unrecognized_fields.get("SomeUnknownField"), "123") + + def test_z_axis_positions(self): + cfg = CeligoHardwareConfig.from_xml(self.path) + z_axis = cfg.z_axis + assert z_axis is not None + self.assertEqual(z_axis.max_position, 14.5) + + def test_filter_wheel(self): + cfg = CeligoHardwareConfig.from_xml(self.path) + fw = cfg.dichroic_filter_wheel + assert fw is not None + self.assertEqual(fw.number_of_filters, 6) + self.assertEqual(len(fw.filter_map), 2) + self.assertEqual(fw.filter_map[0].logical_number, 1) + self.assertEqual(fw.filter_map[0].physical_number, 3) + self.assertEqual(fw.motion_name, "Dichroic") + + def test_io_config(self): + cfg = CeligoHardwareConfig.from_xml(self.path) + io = cfg.io + assert io is not None + self.assertEqual(len(io.lighting_ios), 2) + self.assertEqual(io.lighting_ios[1].io_name, "Green 483/536") + self.assertEqual(len(io.analog_ins), 1) + + def test_source_path_recorded(self): + cfg = CeligoHardwareConfig.from_xml(self.path) + self.assertEqual(cfg.source_path, os.path.abspath(self.path)) + + +class TestDirectConstruction(unittest.TestCase): + def test_user_can_build_in_code(self): + cfg = CeligoHardwareConfig( + x_axis=make_linear_axis_config(motion_name="X", axis_index=1, max_velocity=50.0), + ) + x_axis = cfg.x_axis + assert x_axis is not None + self.assertEqual(x_axis.max_velocity, 50.0) + self.assertIsNone(cfg.y_axis) + + +GALVO_CAL_XML = """ + +
+ + + + 1.3-0.004 + 0.0041.31 + 0.10.2 + + + 0.770 + + + +
+
+""" + +ILLUMINATION_XML = """ + + + 1.6 + 0.2 + 56.5 + 20.2 + + + 1.5 + 0.1 + 4.96.4 + + 17080 + + + 2Green 483/5361 + 7080 + 0.12 + 1.01 + 0.99 + + + 4Blue 377/4470 + 3035 + + + +""" + +CALIBRATION_XML = """ + 1 + 1 + 2048 + 2048 + 0 + 0 + 0 + 0 + 0 + 1 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 +""" + +HARDWARE_DEFAULT_XML = """ + 0 + 0 + 0 + 0 + 0 + 0 + 0 +""" + +NAVIGATION_XML = """ + 0 + 0 + 0 + 0 +""" + + +def _write(xml: str) -> str: + fd, path = tempfile.mkstemp(suffix=".config") + with os.fdopen(fd, "w") as f: + f.write(xml) + return path + + +class TestExtraLoaders(unittest.TestCase): + def test_invalid_boolean_spelling_is_rejected(self): + malformed = SAMPLE_XML.replace("true", "treu", 1) + with self.assertRaisesRegex(ValueError, "Invalid boolean"): + CeligoHardwareConfig.from_xml(_write(malformed)) + + def test_fractional_integer_field_is_rejected(self): + malformed = SAMPLE_XML.replace("1", "1.5", 1) + with self.assertRaisesRegex(ValueError, "AxisIndex must be an integer"): + CeligoHardwareConfig.from_xml(_write(malformed)) + + def test_galvo_voltage_fields(self): + xml = SAMPLE_XML.replace( + "", + '' + "10000" + "100" + "true20" + "200" + "true" + "", + ) + cfg = CeligoHardwareConfig.from_xml(_write(xml)) + x_galvo = cfg.x_galvo + assert x_galvo is not None + self.assertEqual(x_galvo.max_voltage, 10.0) + self.assertTrue(x_galvo.invert_voltage) + self.assertEqual(x_galvo.position_error_window, 20) + + def test_galvo_calibration_terms(self): + cal = load_galvo_calibrations(_write(GALVO_CAL_XML))[1] + self.assertAlmostEqual(cal.forward["LinearXTerm"][0], 1.3) + self.assertAlmostEqual(cal.forward["LinearXTerm"][1], -0.004) + self.assertAlmostEqual(cal.forward["OffsetTerm"][1], 0.2) + self.assertAlmostEqual(cal.reverse["LinearXTerm"][0], 0.77) + + def test_illumination_hardware_recipes(self): + channels = load_illumination_channels(_write(ILLUMINATION_XML)) + self.assertEqual(set(channels), {"brightfield", "green", "blue"}) + self.assertEqual(channels["brightfield"].logical_filter, 1) + self.assertEqual(channels["green"].bit_value, 1) + self.assertEqual(channels["blue"].intensity_percent, 35.0) + self.assertEqual(channels["green"].z_offset_to_brightfield_mm, 0.12) + self.assertEqual(channels["green"].mm_per_pixel_x_correction_to_brightfield, 1.01) + + def test_galvo_optical_centers_and_filter_offsets(self): + calibration = load_galvo_optical_calibration(_write(ILLUMINATION_XML)) + self.assertEqual(calibration.x.magnifications[3].center_voltage, 5.0) + self.assertEqual(calibration.y.magnifications[3].frame_size_volts, 6.4) + self.assertEqual(calibration.x.logical_filter_offsets[2], 0.2) + + def test_missing_galvo_center_is_rejected(self): + malformed = ILLUMINATION_XML.replace("5", "", 1) + with self.assertRaisesRegex(ValueError, "missing center/frame"): + load_galvo_optical_calibration(_write(malformed)) + + +class TestAggregateConfig(unittest.TestCase): + def _write_complete_config(self, directory: str) -> str: + files = { + "USBIOHardwareConfig.config": SAMPLE_XML, + "leaphardwarecalibration.config": ILLUMINATION_XML, + "ChannelConfig.xml": "", + "CalibrationConfig.xml": CALIBRATION_XML, + "HardwareDefaultConfig.xml": HARDWARE_DEFAULT_XML, + "GalvoCalibrationConfig.xml": "", + "NavigationConfig.xml": NAVIGATION_XML, + } + for filename, content in files.items(): + with open(os.path.join(directory, filename), "w") as output: + output.write(content) + return os.path.join(directory, "USBIOHardwareConfig.config") + + def test_loads_complete_config_after_indexing_directory_once(self): + with tempfile.TemporaryDirectory() as directory: + hardware_path = self._write_complete_config(directory) + with patch( + "pylabrobot.revvity.celigo.config.os.listdir", + wraps=os.listdir, + ) as list_directory: + config = CeligoConfig.from_install(hardware_path, magnification=10) + + list_directory.assert_called_once_with(directory) + self.assertEqual(config.magnification, 10) + self.assertEqual(config.hardware.source_path, hardware_path) + self.assertEqual(set(config.channels), {"brightfield", "green", "blue"}) + self.assertEqual(set(config.channels_by_magnification), {3, 10}) + self.assertEqual(config.navigation.frame_overlap_x_mm, 0.0) + + def test_locates_complete_config_via_configfiles_subdirectory(self): + with tempfile.TemporaryDirectory() as install_root: + config_directory = os.path.join(install_root, "ConfigFiles") + os.makedirs(config_directory) + self._write_complete_config(config_directory) + config = CeligoConfig.from_install(install_root) + + self.assertEqual(config.magnification, 3) + x_axis = config.hardware.x_axis + assert x_axis is not None + self.assertEqual(x_axis.motion_name, "X Axis") + + def test_magnification_channels_are_memory_resident_after_load(self): + with tempfile.TemporaryDirectory() as directory: + hardware_path = self._write_complete_config(directory) + config = CeligoConfig.from_install(hardware_path, magnification=10) + + config.magnification = 3 + self.assertEqual(config.channels["brightfield"].intensity_percent, 70) + + def test_missing_hardware_file_raises(self): + with ( + tempfile.TemporaryDirectory() as install_root, + self.assertRaises(FileNotFoundError), + ): + CeligoConfig.from_install(install_root) + + def test_missing_companion_file_fails_during_load(self): + with tempfile.TemporaryDirectory() as directory: + hardware_path = self._write_complete_config(directory) + os.remove(os.path.join(directory, "NavigationConfig.xml")) + with self.assertRaisesRegex(FileNotFoundError, "NavigationConfig.xml"): + CeligoConfig.from_install(hardware_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/revvity/celigo/tests/coordinates_tests.py b/pylabrobot/revvity/celigo/tests/coordinates_tests.py new file mode 100644 index 00000000000..4d9cd3cbc06 --- /dev/null +++ b/pylabrobot/revvity/celigo/tests/coordinates_tests.py @@ -0,0 +1,204 @@ +"""Tests for the Celigo affine coordinate systems and calibration loaders.""" + +import math +import os +import tempfile +import unittest + +from pylabrobot.revvity.celigo.config import ( + CalibrationConfig, + HardwareDefaultConfig, +) +from pylabrobot.revvity.celigo.coordinates import ( + CoordinateSystems, + sample_offset_mm_to_galvo_offset_mm, +) +from pylabrobot.revvity.celigo.tests.helpers import ( + make_calibration_config, + make_hardware_default_config, +) + +CALIB_XML = """ + +
+ + + 1.05456 + 1.05444 + 2048 + 2048 + 0 + 0 + -0.0798 + -0.0361 + 2.566e-06 + 0.99972 + 1.00001 + 1.561e-4 + 2.05e-3 + -1.10e-4 + 2.5654 + 0 + 0 + 0 + + +
+
+""" + +HW_XML = """ + +
+ + + 2.5654 + 2.159 + 3.492 + 2.15 + 2.15 + 1.3 + 1.3 + + +
+
+""" + + +def _write(xml: str) -> str: + fd, path = tempfile.mkstemp(suffix=".xml") + with os.fdopen(fd, "w") as f: + f.write(xml) + return path + + +class TestCalibrationLoaders(unittest.TestCase): + def test_calibration_fields(self): + c = CalibrationConfig.from_xml(_write(CALIB_XML)) + self.assertAlmostEqual(c.microns_per_pixel_x, 1.05456) + self.assertEqual(c.image_width_pixels, 2048) + self.assertAlmostEqual(c.stage_x_scale, 0.99972) + self.assertAlmostEqual(c.stage_shear, 1.561e-4) + self.assertAlmostEqual(c.calibrated_z_position, 2.5654) + + def test_hardware_defaults(self): + h = HardwareDefaultConfig.from_xml(_write(HW_XML)) + self.assertAlmostEqual(h.default_plate_x_corner_stage_coordinate, 2.159) + self.assertAlmostEqual(h.default_x_galvo_mm_per_volt, 1.3) + + +class TestAffineIdentityCase(unittest.TestCase): + """With unit scaling and zero rotation/shear, the math is easy to reason about.""" + + def setUp(self): + self.calib = make_calibration_config( + microns_per_pixel_x=1.0, + microns_per_pixel_y=1.0, # -> 1000 px/mm + image_width_pixels=2048, + image_height_pixels=2048, # center (1024,1024) + image_to_stage_theta_radians=0.0, + calibrated_plate_corner_x=0.0, + calibrated_plate_corner_y=0.0, + calibrated_plate_to_stage_theta_radians=0.0, + stage_x_scale=1.0, + stage_y_scale=1.0, + stage_shear=0.0, + stage_x_shear_offset=0.0, + stage_y_shear_offset=0.0, + ) + self.hw = make_hardware_default_config( + default_plate_x_corner_stage_coordinate=0.0, + default_plate_y_corner_stage_coordinate=0.0, + ) + self.cs = CoordinateSystems.from_config(self.calib, self.hw) + + def test_center_pixel_maps_to_origin(self): + x, y = self.cs.image_pixel_to_sample_mm(1024, 1024) + self.assertAlmostEqual(x, 0.0) + self.assertAlmostEqual(y, 0.0) + + def test_pixel_offset_is_mm(self): + # +1000 px in x == +1 mm at 1000 px/mm + x, y = self.cs.image_pixel_to_sample_mm(2024, 1024) + self.assertAlmostEqual(x, 1.0) + self.assertAlmostEqual(y, 0.0) + + def test_sample_origin_maps_to_plate_corner(self): + cs = CoordinateSystems.from_config( + self.calib, + make_hardware_default_config( + default_plate_x_corner_stage_coordinate=2.159, + default_plate_y_corner_stage_coordinate=3.492, + ), + ) + x, y = cs.sample_mm_to_stage_mm(0.0, 0.0) + self.assertAlmostEqual(x, 2.159) + self.assertAlmostEqual(y, 3.492) + + +class TestGalvoCoordinateConvention(unittest.TestCase): + def test_sample_x_is_reversed_once_at_the_galvo_boundary(self): + self.assertEqual(sample_offset_mm_to_galvo_offset_mm(2.5, -1.25), (-2.5, -1.25)) + + def test_non_finite_offsets_are_rejected(self): + with self.assertRaisesRegex(ValueError, "finite"): + sample_offset_mm_to_galvo_offset_mm(float("nan"), 0) + + +class TestAffineRoundTrips(unittest.TestCase): + """Real-ish calibration values; forward/inverse must round-trip.""" + + def setUp(self): + self.calib = CalibrationConfig.from_xml(_write(CALIB_XML)) + self.hw = HardwareDefaultConfig.from_xml(_write(HW_XML)) + self.cs = CoordinateSystems.from_config(self.calib, self.hw) + + def test_sample_stage_roundtrip(self): + for x, y in ((0.0, 0.0), (12.7, 8.5), (50.0, 30.0)): + sx, sy = self.cs.sample_mm_to_stage_mm(x, y) + rx, ry = self.cs.stage_mm_to_sample_mm(sx, sy) + self.assertAlmostEqual(rx, x, places=4) + self.assertAlmostEqual(ry, y, places=4) + + def test_pixel_sample_roundtrip(self): + for px, py in ((1024, 1024), (1500, 700), (300, 1900)): + mx, my = self.cs.image_pixel_to_sample_mm(px, py) + rpx, rpy = self.cs.sample_mm_to_image_pixel(mx, my) + self.assertAlmostEqual(rpx, px, places=2) + self.assertAlmostEqual(rpy, py, places=2) + + def test_pixel_to_stage_close_to_chained(self): + # GetLowestBaseCoord (pixel->stage) should be close to manually chaining + # pixel->sample then sample->stage (they differ only by the base frame's + # sub-1.0 scale/shear, which is ~identity here). + px, py = 1400, 900 + direct = self.cs.image_pixel_to_stage_mm(px, py) + msx, msy = self.cs.image_pixel_to_sample_mm(px, py) + chained = self.cs.sample_mm_to_stage_mm(msx, msy) + self.assertAlmostEqual(direct[0], chained[0], places=1) + self.assertAlmostEqual(direct[1], chained[1], places=1) + + +class TestRotation(unittest.TestCase): + def test_90_degree_rotation(self): + calib = make_calibration_config( + microns_per_pixel_x=1.0, + microns_per_pixel_y=1.0, + image_width_pixels=2048, + image_height_pixels=2048, + calibrated_plate_to_stage_theta_radians=math.pi / 2, + stage_x_scale=1.0, + stage_y_scale=1.0, + ) + hw = make_hardware_default_config() + cs = CoordinateSystems.from_config(calib, hw) + # sample (1,0) under +90deg plate->stage: GetBaseCoord uses R^-1; for theta=pi/2 + # R^-1 = [[0,-1],[1,0]] so (1,0) -> (0,1). + x, y = cs.sample_mm_to_stage_mm(1.0, 0.0) + self.assertAlmostEqual(x, 0.0, places=6) + self.assertAlmostEqual(y, 1.0, places=6) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/revvity/celigo/tests/device_configuration_tests.py b/pylabrobot/revvity/celigo/tests/device_configuration_tests.py new file mode 100644 index 00000000000..2297b3d3cee --- /dev/null +++ b/pylabrobot/revvity/celigo/tests/device_configuration_tests.py @@ -0,0 +1,524 @@ +"""Tests for config-driven Celigo motion, channels, filters, and drawer positions.""" + +import inspect +import unittest +from unittest.mock import patch + +from pylabrobot.resources.corning.plates import cor_96_wellplate_360uL_Fb +from pylabrobot.revvity.celigo.celigo import Celigo, CeligoError +from pylabrobot.revvity.celigo.config import ( + CeligoHardwareConfig, + DigitalIOConfig, + FilterMapEntry, + IlluminationChannelConfig, + IOConfig, + LightingIOConfig, +) +from pylabrobot.revvity.celigo.motion import Axis +from pylabrobot.revvity.celigo.tests.helpers import ( + FakeCamera, + make_calibration_config, + make_celigo, + make_filter_wheel_config, + make_hardware_default_config, + make_linear_axis_config, + make_test_config, +) + + +def _config() -> CeligoHardwareConfig: + return CeligoHardwareConfig( + x_axis=make_linear_axis_config( + axis_index=1, + max_velocity=45, + max_acceleration=45, + moving_current_percentage=65, + loading_current_percentage=55, + min_position=0, + max_position=20, + mm_per_encoder_tick=1, + ), + y_axis=make_linear_axis_config( + axis_index=2, + max_velocity=40, + max_acceleration=40, + moving_current_percentage=65, + loading_current_percentage=55, + min_position=2, + max_position=20, + home_offset=10, + invert_axis_direction=True, + mm_per_encoder_tick=1, + ), + dichroic_filter_wheel=make_filter_wheel_config( + axis_index=4, + number_of_filters=4, + encoder_ticks_per_revolution=8000, + filter_map=[ + FilterMapEntry(logical_number=2, physical_number=1), + FilterMapEntry(logical_number=3, physical_number=2), + FilterMapEntry(logical_number=4, physical_number=3), + FilterMapEntry(logical_number=1, physical_number=4), + FilterMapEntry(logical_number=5, physical_number=4), + ], + ), + io=IOConfig( + analog_ins=[], + lighting_ios=[ + LightingIOConfig( + config_version=1000, + controller_index=0, + channel=0, + enabled=True, + invert=False, + io_name="eBrightFieldIntensity", + min_voltage=0, + max_voltage=10, + delay=0.0, + ), + LightingIOConfig( + config_version=1000, + controller_index=0, + channel=2, + enabled=True, + invert=False, + io_name="eFluorescentIntensity", + min_voltage=0, + max_voltage=10, + delay=0.0, + ), + ], + digital_ios=[ + DigitalIOConfig( + config_version=1000, + io_type="Out", + bit_index=4, + invert=False, + enabled=True, + io_name="FLBit0", + ), + DigitalIOConfig( + config_version=1000, + io_type="Out", + bit_index=5, + invert=False, + enabled=True, + io_name="FLBit1", + ), + DigitalIOConfig( + config_version=1000, + io_type="Out", + bit_index=6, + invert=False, + enabled=True, + io_name="FLOnOff", + ), + ], + ), + ) + + +def _channels(): + return { + "green": IlluminationChannelConfig( + name="green", + display_name="Green 483/536", + logical_filter=2, + bit_value=1, + intensity_percent=80, + lighting_io_name="eFluorescentIntensity", + strobe=True, + z_offset_to_brightfield_mm=0.0, + mm_per_pixel_x_correction_to_brightfield=1.0, + mm_per_pixel_y_correction_to_brightfield=1.0, + ) + } + + +class TestConfiguredFilter(unittest.IsolatedAsyncioTestCase): + async def test_logical_filter_uses_map_and_shortest_equivalent_target(self): + driver = make_celigo(hardware=_config()) + driver.dichroic_filter._home_encoder_ticks = 4020 + targets = [] + + async def request_encoder_ticks(): + return 2020 + + async def move_axis(_axis, target, **_kwargs): + targets.append(target) + return target + + with ( + patch.object(driver.dichroic_filter, "request_encoder_ticks", request_encoder_ticks), + patch.object(Axis, "move_to_ticks", new=move_axis), + ): + self.assertEqual(await driver.dichroic_filter.move_to(3), -1980) + self.assertEqual(targets, [-1980]) + + async def test_magnification_changer_updates_active_calibration(self): + driver = make_celigo( + hardware=CeligoHardwareConfig( + magnification_changer=make_filter_wheel_config( + axis_index=8, + number_of_filters=4, + encoder_ticks_per_revolution=8000, + filter_map=[FilterMapEntry(logical_number=5, physical_number=2)], + ) + ) + ) + driver.magnification_changer._home_encoder_ticks = 0 + + async def request_encoder_ticks(): + return 0 + + async def move_axis(_axis, target, **_kwargs): + return target + + with ( + patch.object(driver.magnification_changer, "request_encoder_ticks", request_encoder_ticks), + patch.object(Axis, "move_to_ticks", new=move_axis), + ): + self.assertEqual(await driver.magnification_changer.move_to(5), 2000) + self.assertEqual(driver.config.magnification, 5) + + +class TestConfiguredMotorAddress(unittest.TestCase): + def test_standard_axis_uses_loaded_address(self): + celigo = make_celigo( + hardware=CeligoHardwareConfig(x_axis=make_linear_axis_config(axis_index=7)) + ) + self.assertEqual(celigo.x_axis.axis_index, 7) + + def test_linear_axis_owns_position_unit_conversion(self): + celigo = make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config( + axis_index=1, + mm_per_encoder_tick=0.0127, + home_offset=-18.0, + ), + y_axis=make_linear_axis_config( + axis_index=2, + mm_per_encoder_tick=0.0127, + home_offset=71.75, + invert_axis_direction=True, + ), + ) + ) + self.assertEqual(celigo.x_axis.mm_to_encoder_ticks(10.0), round((10 - 18) / 0.0127)) + for axis, position_mm in ( + (celigo.x_axis, 23.7), + (celigo.y_axis, 12.3), + ): + encoder_ticks = axis.mm_to_encoder_ticks(position_mm) + self.assertAlmostEqual(axis.encoder_ticks_to_mm(encoder_ticks), position_mm, places=1) + + +class TestConfiguredChannel(unittest.IsolatedAsyncioTestCase): + async def test_channel_selection_leaves_illumination_off_until_enabled(self): + driver = make_celigo(hardware=_config()) + driver.config.channels_by_magnification[driver.config.magnification] = _channels() + driver.current_channel = None + moves = [] + digital = [] + analog = [] + + async def move_filter(logical): + moves.append(logical) + return 0 + + async def set_digital(bit, on): + digital.append((bit, on)) + + async def set_analog_output_count(channel, value): + analog.append((channel, value)) + + with ( + patch.object(driver.dichroic_filter, "move_to", move_filter), + patch.multiple( + driver, + set_digital_output=set_digital, + set_analog_output_count=set_analog_output_count, + ), + ): + await driver.select_channel("green") + self.assertEqual(moves, [2]) + self.assertEqual(digital, [(6, False), (4, False), (5, True)]) + self.assertEqual(analog, [(0, 0), (2, 0)]) + self.assertEqual(driver.current_channel, "green") + + await driver.set_illumination_enabled(True) + self.assertEqual(analog[-1], (2, 3276)) + self.assertEqual(digital[-1], (6, True)) + + async def test_channel_intensity_override_is_a_percentage(self): + driver = make_celigo(hardware=_config()) + driver.config.channels_by_magnification[driver.config.magnification] = _channels() + analog = [] + + async def no_op(*_args, **_kwargs): + return None + + async def set_analog_output_count(channel_index, dac_count): + analog.append((channel_index, dac_count)) + + with ( + patch.object(driver.dichroic_filter, "move_to", no_op), + patch.multiple( + driver, + set_digital_output=no_op, + set_analog_output_count=set_analog_output_count, + ), + ): + await driver.select_channel("green") + await driver.set_illumination_enabled(True, intensity_percent=30) + + self.assertEqual(analog[-1], (2, 1228)) + + async def test_channel_control_respects_inverted_outputs(self): + hardware = _config() + io_config = hardware.io + assert io_config is not None + for output in io_config.digital_ios: + output.invert = True + io_config.lighting_ios[1].invert = True + driver = make_celigo(hardware=hardware) + driver.config.channels_by_magnification[driver.config.magnification] = _channels() + digital = [] + analog = [] + + async def no_op(*_args, **_kwargs): + return None + + async def set_digital(bit, high): + digital.append((bit, high)) + + async def set_analog(channel, value): + analog.append((channel, value)) + + with ( + patch.object(driver.dichroic_filter, "move_to", no_op), + patch.multiple( + driver, + set_digital_output=set_digital, + set_analog_output_count=set_analog, + ), + ): + await driver.select_channel("green") + await driver.set_illumination_enabled(True) + await driver.turn_off_illumination() + + self.assertEqual(digital[:3], [(6, True), (4, True), (5, False)]) + self.assertEqual(digital[3], (6, False)) + self.assertEqual(digital[4], (6, True)) + self.assertEqual(analog[:2], [(0, 0), (2, 4095)]) + self.assertEqual(analog[2], (2, 819)) + self.assertEqual(analog[-1], (2, 4095)) + + async def test_channel_selection_rejects_disabled_output_before_motion(self): + hardware = _config() + io_config = hardware.io + assert io_config is not None + io_config.lighting_ios[1].enabled = False + driver = make_celigo(hardware=hardware) + driver.config.channels_by_magnification[driver.config.magnification] = _channels() + moves = [] + + async def move_filter(logical_filter): + moves.append(logical_filter) + return 0 + + with ( + patch.object(driver.dichroic_filter, "move_to", move_filter), + self.assertRaisesRegex(CeligoError, "is disabled"), + ): + await driver.select_channel("green") + self.assertEqual(moves, []) + + async def test_channel_selection_rejects_input_only_selector_before_motion(self): + hardware = _config() + io_config = hardware.io + assert io_config is not None + io_config.digital_ios[0].io_type = "In" + driver = make_celigo(hardware=hardware) + driver.config.channels_by_magnification[driver.config.magnification] = _channels() + moves = [] + + async def move_filter(logical_filter): + moves.append(logical_filter) + return 0 + + with ( + patch.object(driver.dichroic_filter, "move_to", move_filter), + self.assertRaisesRegex(CeligoError, "not configured as an output"), + ): + await driver.select_channel("green") + self.assertEqual(moves, []) + + +class TestConfiguredDrawer(unittest.IsolatedAsyncioTestCase): + def test_sample_load_position_uses_coordinate_and_axis_calibration(self): + driver = make_celigo(hardware=_config()) + driver.config.calibration = make_calibration_config() + driver.config.hardware_defaults = make_hardware_default_config( + default_plate_x_corner_stage_coordinate=2, + default_plate_y_corner_stage_coordinate=3, + ) + targets = driver._drawer_load_targets_from_sample_mm(4, 5) + self.assertEqual(targets.x_park_mm, 6) + self.assertEqual(targets.y_clearance_mm, 2) + self.assertEqual(targets.y_park_mm, 8) + + async def test_close_to_well_requires_set_plate(self): + driver = make_celigo(hardware=_config()) + with self.assertRaisesRegex(CeligoError, r"set_plate\(\)"): + await driver.close_drawer("A1") + + async def test_close_to_well_delegates_to_sample_coordinates(self): + driver = make_celigo(hardware=_config()) + driver.config.calibration = make_calibration_config() + driver.config.hardware_defaults = make_hardware_default_config() + driver.set_plate(cor_96_wellplate_360uL_Fb(name="imaging_plate")) + calls = [] + + async def close_to_sample(x_mm, y_mm): + calls.append((x_mm, y_mm)) + + with patch.object(driver, "close_drawer_to_sample_mm", close_to_sample): + await driver.close_drawer("A1") + self.assertEqual(len(calls), 1) + self.assertAlmostEqual(calls[0][0], 14.3) + self.assertAlmostEqual(calls[0][1], 11.28) + + async def test_close_to_sample_retracts_z_and_moves_via_y_clearance(self): + hardware = _config() + hardware.z_axis = make_linear_axis_config( + axis_index=3, + min_position=0, + max_position=10, + mm_per_encoder_tick=1, + ) + driver = make_celigo(hardware=hardware) + driver.config.calibration = make_calibration_config() + driver.config.hardware_defaults = make_hardware_default_config( + default_plate_x_corner_stage_coordinate=2, + default_plate_y_corner_stage_coordinate=3, + ) + driver.current_channel = "brightfield" + calls = [] + + async def turn_off(): + calls.append(("illumination", None)) + + async def move_z(position): + calls.append(("z", position)) + return position + + async def move_x(position): + calls.append(("x", position)) + return position + + async def move_y(position): + calls.append(("y", position)) + return position + + with ( + patch.object(driver, "turn_off_illumination", turn_off), + patch.object(driver.z_axis, "move_to", move_z), + patch.object(driver.x_axis, "move_to", move_x), + patch.object(driver.y_axis, "move_to", move_y), + ): + await driver.close_drawer_to_sample_mm(4, 5) + self.assertIsNone(driver.current_channel) + self.assertEqual( + calls, + [ + ("illumination", None), + ("z", 0), + ("y", 2), + ("x", 6), + ("y", 8), + ], + ) + + async def test_close_to_sample_rejects_invalid_targets_before_motion(self): + driver = make_celigo(hardware=_config()) + driver.config.calibration = make_calibration_config() + driver.config.hardware_defaults = make_hardware_default_config() + + async def unexpected(): + self.fail("invalid drawer target changed illumination") + + with patch.object(driver, "turn_off_illumination", unexpected): + with self.assertRaisesRegex(ValueError, "must be finite"): + await driver.close_drawer_to_sample_mm(float("nan"), 5) + with self.assertRaisesRegex(CeligoError, "outside configured range"): + await driver.close_drawer_to_sample_mm(100, 5) + + async def test_open_drawer_retries_and_requires_target_limit(self): + hardware = _config() + hardware.z_axis = make_linear_axis_config( + axis_index=3, min_position=0, max_position=10, mm_per_encoder_tick=1 + ) + driver = make_celigo(hardware=hardware) + attempted = [] + + async def no_op(*_args, **_kwargs): + return 0 + + async def no_limit(): + return False + + async def relative( + distance_ticks, + move_current_percent=None, + ): + attempted.append(("x", distance_ticks, move_current_percent)) + + with ( + patch.object(driver, "turn_off_illumination", no_op), + patch.object(driver.z_axis, "move_to", no_op), + patch.object(driver.y_axis, "move_to", no_op), + patch.multiple( + driver.x_axis, + request_is_negative_limit_active=no_limit, + _limit_move_distance_ticks=lambda: 5, + _move_relative_to_limit=relative, + ), + patch.object(driver.y_axis, "_limit_move_distance_ticks", lambda: 5), + self.assertRaisesRegex(CeligoError, "X limit was not reached"), + ): + await driver.open_drawer() + self.assertEqual(attempted, [("x", -5, 55)] * 3) + + +class TestCompanionConfigurationLoading(unittest.TestCase): + def test_constructor_builds_camera_from_lucam_sdk(self): + with ( + patch("pylabrobot.revvity.celigo.celigo.FTDI"), + patch("pylabrobot.revvity.celigo.celigo.LumeneraCamera", FakeCamera), + ): + celigo = Celigo( + lucam_sdk="/opt/lumenera/liblucamapi.so", + config=make_test_config(), + ) + + self.assertIsInstance(celigo.camera, FakeCamera) + camera = celigo.camera + assert isinstance(camera, FakeCamera) + self.assertEqual(camera.sdk_library, "/opt/lumenera/liblucamapi.so") + + def test_constructor_requires_an_explicit_aggregate_config(self): + with self.assertRaisesRegex(TypeError, "config"): + inspect.signature(Celigo).bind() + + def test_constructor_uses_explicit_aggregate_config(self): + config = make_test_config() + with patch("pylabrobot.revvity.celigo.celigo.FTDI"): + celigo = Celigo(config=config) + + self.assertIs(celigo.config, config) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/revvity/celigo/tests/feature_tests.py b/pylabrobot/revvity/celigo/tests/feature_tests.py new file mode 100644 index 00000000000..043e68b7093 --- /dev/null +++ b/pylabrobot/revvity/celigo/tests/feature_tests.py @@ -0,0 +1,1904 @@ +"""Tests for Celigo startup, camera, focus, and laser safety.""" + +import asyncio +import ctypes +import struct +import threading +import unittest +import zlib +from unittest.mock import AsyncMock, patch + +from pylabrobot.revvity.celigo.camera import CameraError, CameraFrame, LumeneraCamera +from pylabrobot.revvity.celigo.celigo import ( + CeligoError, + ControllerInfo, + ControllerStatus, + DetectedMotorAddress, +) +from pylabrobot.revvity.celigo.config import ( + CeligoHardwareConfig, + DigitalIOConfig, + ExternalCameraControlConfig, + FilterWheelConfig, + GalvoAxisOpticalCalibration, + GalvoMagnificationCalibration, + GalvoOpticalCalibration, + IlluminationChannelConfig, + IOConfig, + LightingIOConfig, +) +from pylabrobot.revvity.celigo.galvo import ( + _CMD_CALIBRATE_GALVO, + _CMD_MOVE_GALVO, + GalvoControllerStatus, + dac_count_to_volts, +) +from pylabrobot.revvity.celigo.laser import ( + _CMD_FIRE_GALVO_GRID, + _CMD_FIRE_LASER, + _CMD_LOAD_FIRING_TABLE, + _CMD_READ_LASER_COMM, + _CMD_SEND_LASER_COMM, + _CMD_TARGETED_FIRE, + Laser, +) +from pylabrobot.revvity.celigo.motion import ( + _LIMIT_OPTO_1, + Axis, + _parse_motor_controller_firmware_version, +) +from pylabrobot.revvity.celigo.tests.helpers import ( + make_calibration_config, + make_celigo, + make_filter_wheel_config, + make_galvo_config, + make_linear_axis_config, +) + + +def _filter_config() -> FilterWheelConfig: + return make_filter_wheel_config( + motion_name="Dichroic", + axis_index=4, + enabled=True, + home_type="Filter_Accurate", + home_offset=20, + index_velocity=600, + homing_velocity=5000, + max_velocity=30000, + max_acceleration=5000, + moving_current_percentage=80, + holding_current_percentage=30, + default_positive_direction=True, + limit_polarity=1, + mode_enable_position_correction=True, + encoder_to_motor_tick_ratio=25.6, + moving_overload_limit=10, + coarse_position_error_window=20, + fine_position_error_window=1, + gain=5, + motor_response_time=2, + encoder_ticks_per_revolution=8000, + number_of_filters=4, + filter_map=[], + ) + + +def _galvo_controller_status( + *, + fire_table_size: int, + points_loaded: int, + fire_table_index: int, +) -> GalvoControllerStatus: + return GalvoControllerStatus( + x_busy=False, + y_busy=False, + x_hardware_voltage=0.0, + y_hardware_voltage=0.0, + fire_table_size=fire_table_size, + points_loaded=points_loaded, + fire_table_index=fire_table_index, + firing_status=0, + capture_armed=False, + capture_table_size=0, + ) + + +class TestMotorStartup(unittest.IsolatedAsyncioTestCase): + async def test_hardware_initialization_requires_galvos_as_a_pair(self): + celigo = make_celigo() + celigo.config.hardware.x_galvo = make_galvo_config() + + async def no_op(): + return None + + async def controller_info(): + return ControllerInfo(device_index=0, firmware_version=(1, 3, 0), uart_buffer_length=64) + + async def detected_motor_addresses(): + return [] + + with ( + patch.multiple( + celigo, + abort_controller_operation=no_op, + request_controller_info=controller_info, + request_detected_motor_addresses=detected_motor_addresses, + _initialize_safe_outputs=no_op, + ), + self.assertRaisesRegex(CeligoError, "both be enabled or both be absent"), + ): + await celigo._initialize_hardware() + + async def test_hardware_initialization_centers_galvos_for_active_magnification(self): + celigo = make_celigo() + celigo.config.magnification = 5 + celigo.config.hardware.x_galvo = make_galvo_config() + celigo.config.hardware.y_galvo = make_galvo_config() + centered_magnifications = [] + + async def no_op(*_args, **_kwargs): + return None + + async def controller_info(): + return ControllerInfo(device_index=0, firmware_version=(1, 3, 0), uart_buffer_length=64) + + async def detected_motor_addresses(): + return [] + + async def calibrate(*_args, **_kwargs): + return True + + async def home(*, magnification, logical_filter=None): + del logical_filter + centered_magnifications.append(magnification) + return 0.0, 0.0 + + with ( + patch.multiple( + celigo, + abort_controller_operation=no_op, + request_controller_info=controller_info, + request_detected_motor_addresses=detected_motor_addresses, + _initialize_safe_outputs=no_op, + ), + patch.multiple( + celigo.galvo, + _set_settling_window=no_op, + calibrate=calibrate, + home=home, + ), + ): + await celigo._initialize_hardware() + + self.assertEqual(centered_magnifications, [5]) + + async def test_home_imaging_axes_synchronizes_magnification_with_z_retracted(self): + celigo = make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config(axis_index=1), + y_axis=make_linear_axis_config(axis_index=2), + z_axis=make_linear_axis_config(axis_index=3), + dichroic_filter_wheel=make_filter_wheel_config(axis_index=4), + magnification_changer=make_filter_wheel_config(axis_index=5), + ) + ) + celigo.config.magnification = 5 + operations = [] + + async def track(operation): + operations.append(operation) + + async def move_magnification(magnification): + operations.append(f"magnification_move_{magnification}") + return 0 + + with ( + patch.object(celigo, "turn_off_illumination", lambda: track("illumination_off")), + patch.object(celigo.z_axis, "home", lambda: track("z_home")), + patch.multiple( + celigo.magnification_changer, + home=lambda: track("magnification_home"), + move_to=move_magnification, + ), + patch.object(celigo.x_axis, "home", lambda: track("x_home")), + patch.object(celigo.y_axis, "home", lambda: track("y_home")), + patch.object(celigo.dichroic_filter, "home", lambda: track("dichroic_home")), + ): + await celigo.home_imaging_axes() + + self.assertEqual( + operations, + [ + "illumination_off", + "z_home", + "magnification_home", + "magnification_move_5", + "x_home", + "y_home", + "dichroic_home", + ], + ) + + async def test_hardware_initialization_rejects_an_undetected_configured_motor(self): + celigo = make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config( + motion_name="X Axis", + axis_index=1, + ) + ) + ) + + async def no_op(): + return None + + async def controller_info(): + return ControllerInfo(device_index=0, firmware_version=(1, 3, 0), uart_buffer_length=64) + + async def detected_motor_addresses(): + return [DetectedMotorAddress(uart_index=0, motor_index=2)] + + with ( + patch.multiple( + celigo, + abort_controller_operation=no_op, + request_controller_info=controller_info, + request_detected_motor_addresses=detected_motor_addresses, + ), + self.assertRaisesRegex(CeligoError, r"X Axis \(1\)"), + ): + await celigo._initialize_hardware() + + def test_duplicate_enabled_motor_addresses_are_rejected(self): + with self.assertRaisesRegex(CeligoError, "share motor address 1"): + make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config(motion_name="X Axis", axis_index=1), + y_axis=make_linear_axis_config(motion_name="Y Axis", axis_index=1), + ) + ) + + def test_motion_profile_rejects_invalid_configured_rates(self): + axis_config = _filter_config() + axis_config.max_acceleration = 0 + celigo = make_celigo(hardware=CeligoHardwareConfig(dichroic_filter_wheel=axis_config)) + + with self.assertRaisesRegex(CeligoError, "invalid configured rate 0"): + celigo.dichroic_filter._motion_profile() + + def test_motor_firmware_version_is_structured_not_float(self): + self.assertEqual( + _parse_motor_controller_firmware_version("EZStepper Controller V7.21"), + (7, 21), + ) + self.assertLess( + _parse_motor_controller_firmware_version("EZStepper Controller V7.9"), + (7, 12), + ) + with self.assertRaisesRegex(CeligoError, "Could not parse"): + _parse_motor_controller_firmware_version("EZStepper Controller unknown") + + async def test_safe_output_initialization_zeros_all_vendor_outputs(self): + celigo = make_celigo() + analog = [] + digital = [] + + async def dac(channel, value): + analog.append((channel, value)) + + async def output(bit, on): + digital.append((bit, on)) + + with patch.multiple( + celigo, + set_analog_output_count=dac, + set_digital_output=output, + ): + await celigo._initialize_safe_outputs() + self.assertEqual(analog, [(0, 0), (1, 0), (2, 0), (3, 0)]) + self.assertEqual(digital, [(bit, False) for bit in range(12)]) + + async def test_safe_output_initialization_respects_inverted_outputs_and_delay(self): + inverted_light = LightingIOConfig( + config_version=1000, + controller_index=0, + channel=2, + enabled=True, + invert=True, + io_name="eFluorescentIntensity", + min_voltage=0, + max_voltage=10, + delay=0.025, + ) + inverted_lamp_power = DigitalIOConfig( + config_version=1000, + io_type="Out", + bit_index=7, + invert=True, + enabled=True, + io_name="ExcitationLampPower", + ) + celigo = make_celigo( + hardware=CeligoHardwareConfig( + io=IOConfig( + analog_ins=[], + digital_ios=[inverted_lamp_power], + lighting_ios=[inverted_light], + ) + ) + ) + analog = [] + digital = [] + + async def set_analog(channel, value): + analog.append((channel, value)) + + async def set_digital(bit, high): + digital.append((bit, high)) + + with ( + patch.multiple( + celigo, + set_analog_output_count=set_analog, + set_digital_output=set_digital, + ), + patch("pylabrobot.revvity.celigo.celigo.asyncio.sleep", new_callable=AsyncMock) as sleep, + ): + await celigo._initialize_safe_outputs() + + self.assertEqual(analog, [(0, 0), (1, 0), (2, 4095), (3, 0)]) + self.assertEqual(digital, [(bit, bit == 7) for bit in range(12)]) + sleep.assert_awaited_once_with(0.025) + self.assertIsNone(celigo._fluorescence_on_since) + + async def test_initialization_replays_vendor_tokens(self): + celigo = make_celigo(hardware=CeligoHardwareConfig(dichroic_filter_wheel=_filter_config())) + commands = [] + + async def motor_query(command): + commands.append(command) + data = "EZStepper Controller V7.21" if command.endswith("&\r") else "" + return f"/0`{data}" + + with patch.object(celigo.motor_controller, "send_command", motor_query): + await celigo.dichroic_filter._initialize() + self.assertEqual(commands[0], "/4&\r") + self.assertEqual(commands[1], "/4T\r") + self.assertEqual(commands[2], "/4N32R\r") + self.assertEqual( + commands[3], + "/4F0f1m80h30aE25600au10aC20ac1x5V30000L5000aP2R\r", + ) + self.assertEqual(commands[4], "/4n0R\r") + + async def test_failed_reinitialization_clears_axis_state(self): + celigo = make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config( + axis_index=1, + max_velocity=1, + max_acceleration=1, + ) + ) + ) + celigo.x_axis._initialized = True + celigo.x_axis._supports_accurate_encoder_index = True + + async def fail_firmware_query(): + raise CeligoError("firmware query failed") + + with ( + patch.object( + celigo.x_axis.motor, + "request_motor_controller_firmware_version", + fail_firmware_query, + ), + self.assertRaisesRegex(CeligoError, "firmware query failed"), + ): + await celigo.x_axis._initialize() + self.assertFalse(celigo.x_axis.is_initialized) + self.assertFalse(celigo.x_axis._supports_accurate_encoder_index) + + async def test_illumination_shutdown_does_not_require_a_strobe_output(self): + lighting_outputs = [ + LightingIOConfig( + config_version=1000, + controller_index=0, + channel=0, + enabled=True, + invert=False, + io_name="brightfield", + min_voltage=0, + max_voltage=10, + delay=0.0, + ), + LightingIOConfig( + config_version=1000, + controller_index=0, + channel=2, + enabled=True, + invert=False, + io_name="fluorescence", + min_voltage=0, + max_voltage=10, + delay=0.0, + ), + ] + celigo = make_celigo( + hardware=CeligoHardwareConfig( + io=IOConfig( + analog_ins=[], + digital_ios=[], + lighting_ios=lighting_outputs, + ) + ) + ) + analog_writes = [] + + async def write_analog(channel, value): + analog_writes.append((channel, value)) + + with patch.object(celigo, "set_analog_output_count", write_analog): + await celigo.turn_off_illumination() + self.assertEqual(analog_writes, [(0, 0), (2, 0)]) + + async def test_illumination_shutdown_zeros_analog_outputs_after_strobe_failure(self): + strobe = DigitalIOConfig( + config_version=1000, + io_type="Out", + bit_index=6, + invert=False, + enabled=True, + io_name="FLOnOff", + ) + lighting = LightingIOConfig( + config_version=1000, + controller_index=0, + channel=2, + enabled=True, + invert=False, + io_name="fluorescence", + min_voltage=0, + max_voltage=10, + delay=0.0, + ) + celigo = make_celigo( + hardware=CeligoHardwareConfig( + io=IOConfig( + analog_ins=[], + digital_ios=[strobe], + lighting_ios=[lighting], + ) + ) + ) + analog_writes = [] + + async def fail_strobe(_bit_index, _enabled): + raise CeligoError("strobe write failed") + + async def write_analog(channel, value): + analog_writes.append((channel, value)) + + with ( + patch.multiple( + celigo, + set_digital_output=fail_strobe, + set_analog_output_count=write_analog, + ), + self.assertRaisesRegex(CeligoError, "strobe write failed"), + ): + await celigo.turn_off_illumination() + self.assertEqual(analog_writes, [(2, 0)]) + + async def test_configured_move_restores_hold_current_after_failure(self): + celigo = make_celigo(hardware=CeligoHardwareConfig(dichroic_filter_wheel=_filter_config())) + commands = [] + + async def send(command): + commands.append(command) + return "/0`" + + async def fail_wait(*_args, **_kwargs): + raise TimeoutError("simulated timeout") + + with ( + patch.object(celigo.motor_controller, "send_command", send), + patch.object(celigo.dichroic_filter.motor, "wait_until_ready", fail_wait), + self.assertRaises(TimeoutError), + ): + await celigo.dichroic_filter.move_to_ticks(2000) + + self.assertEqual(commands.count("/4T\r"), 3) + self.assertEqual(commands[-1], "/4h30R\r") + + async def test_cancelled_move_terminates_and_restores_hold_current(self): + celigo = make_celigo(hardware=CeligoHardwareConfig(dichroic_filter_wheel=_filter_config())) + wait_started = asyncio.Event() + cleanup_operations = [] + + class SuccessfulResponse: + ok = True + error_code = 0 + + async def send(*_args, **_kwargs): + return SuccessfulResponse() + + async def wait_until_ready(*_args, **_kwargs): + wait_started.set() + await asyncio.Future() + + async def terminate(): + cleanup_operations.append("terminate") + + async def set_parameter(token, value, _description): + cleanup_operations.append(f"{token}{value}") + + with patch.multiple( + celigo.dichroic_filter.motor, + send_command=send, + wait_until_ready=wait_until_ready, + _terminate=terminate, + _set_parameter=set_parameter, + ): + move = asyncio.create_task(celigo.dichroic_filter.move_to_ticks(2000)) + await wait_started.wait() + move.cancel() + with self.assertRaises(asyncio.CancelledError): + await move + + self.assertEqual(cleanup_operations, ["terminate", "h30"]) + + async def test_filter_move_uses_fine_window_and_retries(self): + celigo = make_celigo(hardware=CeligoHardwareConfig(dichroic_filter_wheel=_filter_config())) + waits = iter((102, 100)) + wait_count = 0 + + async def send(_command): + return "/0`" + + async def wait(*_args, **_kwargs): + nonlocal wait_count + wait_count += 1 + return next(waits) + + with ( + patch.object(celigo.motor_controller, "send_command", send), + patch.object(celigo.dichroic_filter.motor, "wait_until_ready", wait), + ): + self.assertEqual(await celigo.dichroic_filter.move_to_ticks(100), 100) + self.assertEqual(wait_count, 2) + + async def test_normal_accurate_home_checks_limit_indexes_and_moves_to_minimum(self): + axis = make_linear_axis_config( + motion_name="X Axis", + axis_index=1, + enabled=True, + home_type="Normal_Accurate", + homing_velocity=15, + index_velocity=3, + homing_short_move=2000, + home_offset=-18, + min_position=3, + max_position=125, + mm_per_encoder_tick=0.0127, + max_velocity=45, + max_acceleration=45, + negative_limit=True, + mode_enable_limits=True, + mode_enable_position_correction=True, + s_curve_support=True, + fine_position_error_window=1, + ) + celigo = make_celigo(hardware=CeligoHardwareConfig(x_axis=axis)) + celigo.move_timeout = 30.0 + celigo.x_axis._initialized = True + celigo.x_axis._supports_accurate_encoder_index = True + encoder_positions = iter((100, 105)) + flags = iter((_LIMIT_OPTO_1, 0)) + relative_moves = [] + index_homes = [] + absolute_moves = [] + + async def encoder(): + return next(encoder_positions) + + async def relative(positive, distance, velocity): + relative_moves.append((positive, distance, velocity)) + return 0 + + async def get_flags(): + return next(flags) + + async def no_op(*_args, **_kwargs): + return None + + async def index_home(distance, velocity, mode, **_kwargs): + index_homes.append((distance, velocity, mode)) + return 0 + + async def absolute(_axis, target, **_kwargs): + absolute_moves.append(target) + return target + + with ( + patch.multiple( + celigo.x_axis, + request_encoder_ticks=encoder, + _move_homing_relative_ticks=relative, + request_limit_flags=get_flags, + _restore_homing_configuration=no_op, + _home_to_encoder_index=index_home, + ), + patch.multiple(celigo.x_axis.motor, _set_mode=no_op, _set_parameter=no_op), + patch.object(Axis, "move_to_ticks", new=absolute), + ): + self.assertEqual(await celigo.x_axis.home(), -1181) + self.assertEqual( + relative_moves, + [(True, 5, 3543), (False, 25000, 1181), (True, 2000, 1181)], + ) + self.assertEqual(index_homes, [(4000, 236, 6)]) + self.assertEqual(absolute_moves, [0, -1181]) + self.assertTrue(celigo.x_axis.has_position_reference) + + async def test_z_home_uses_no_index_mode_and_a_worst_case_timeout(self): + axis = make_linear_axis_config( + motion_name="Z Axis", + axis_index=3, + enabled=True, + home_type="NormalWithHardstopCheck", + homing_velocity=4, + index_velocity=0.15, + homing_short_move=2000, + home_offset=0.05, + min_position=0, + max_position=6.5, + mm_per_encoder_tick=0.000396875, + max_velocity=4, + max_acceleration=4, + negative_limit=True, + mode_enable_limits=True, + mode_enable_position_correction=True, + ) + celigo = make_celigo(hardware=CeligoHardwareConfig(z_axis=axis)) + celigo.move_timeout = 30.0 + celigo.z_axis._initialized = True + celigo.z_axis._supports_accurate_encoder_index = True + encoders = iter((100, 105)) + flags = iter((_LIMIT_OPTO_1, 0)) + index_home = [] + + async def no_op(*_args, **_kwargs): + return None + + async def encoder(): + return next(encoders) + + async def relative(*_args, **_kwargs): + return 0 + + async def get_flags(): + return next(flags) + + async def home_index(distance, velocity, mode, **kwargs): + index_home.append((distance, velocity, mode, kwargs["timeout"])) + return 0 + + async def absolute(_axis, target, **_kwargs): + return target + + with ( + patch.multiple(celigo.z_axis.motor, _set_mode=no_op, _set_parameter=no_op), + patch.multiple( + celigo.z_axis, + _restore_homing_configuration=no_op, + request_encoder_ticks=encoder, + _move_homing_relative_ticks=relative, + request_limit_flags=get_flags, + _home_to_encoder_index=home_index, + ), + patch.object(Axis, "move_to_ticks", new=absolute), + ): + self.assertEqual(await celigo.z_axis.home(), 126) + self.assertEqual(index_home, [(25000, 378, 1, 68.13756613756614)]) + self.assertTrue(celigo.z_axis.has_position_reference) + + async def test_home_fails_closed_when_negative_limit_does_not_activate(self): + axis = make_linear_axis_config( + motion_name="X Axis", + axis_index=1, + enabled=True, + home_type="Normal_Accurate", + homing_velocity=15, + index_velocity=3, + homing_short_move=2000, + home_offset=-18, + min_position=3, + max_position=125, + mm_per_encoder_tick=0.0127, + max_velocity=45, + max_acceleration=45, + negative_limit=True, + mode_enable_limits=True, + ) + celigo = make_celigo(hardware=CeligoHardwareConfig(x_axis=axis)) + celigo.move_timeout = 30.0 + celigo.x_axis._initialized = True + celigo.x_axis._supports_accurate_encoder_index = True + encoders = iter((100, 105)) + commands = [] + restored = 0 + + async def encoder(): + return next(encoders) + + async def relative(*_args, **_kwargs): + return 0 + + async def get_flags(): + return 0 + + async def no_op(*_args, **_kwargs): + return None + + async def restore(): + nonlocal restored + restored += 1 + + async def terminate(): + commands.append((1, "T", False)) + + with ( + patch.multiple( + celigo.x_axis, + request_encoder_ticks=encoder, + _move_homing_relative_ticks=relative, + request_limit_flags=get_flags, + _restore_homing_configuration=restore, + ), + patch.multiple( + celigo.x_axis.motor, + _set_mode=no_op, + _set_parameter=no_op, + _terminate=terminate, + ), + self.assertRaisesRegex(CeligoError, "without activating"), + ): + await celigo.x_axis.home() + self.assertFalse(celigo.x_axis.has_position_reference) + self.assertEqual(commands[-1], (1, "T", False)) + self.assertEqual(restored, 1) + + +class TestAccurateFilterHome(unittest.IsolatedAsyncioTestCase): + async def test_index_timeout_terminates_and_restores_configured_mode(self): + celigo = make_celigo(hardware=CeligoHardwareConfig(dichroic_filter_wheel=_filter_config())) + commands = [] + + async def send(command): + commands.append(command) + return "/0`" + + async def timeout(*_args, **_kwargs): + raise TimeoutError("simulated index timeout") + + with ( + patch.object(celigo.motor_controller, "send_command", send), + patch.object(celigo.dichroic_filter.motor, "wait_until_ready", timeout), + self.assertRaises(TimeoutError), + ): + await celigo.dichroic_filter._home_to_encoder_index( + 2400, + 600, + 6, + timeout=5, + ) + self.assertIn("/4T\r", commands) + self.assertTrue(commands[-1].startswith("/4n")) + + async def test_scans_physical_positions_until_opto(self): + celigo = make_celigo(hardware=CeligoHardwareConfig(dichroic_filter_wheel=_filter_config())) + celigo.move_timeout = 30.0 + celigo.dichroic_filter._initialized = True + celigo.dichroic_filter._supports_accurate_encoder_index = True + moves = [] + flags = iter((0, 0, _LIMIT_OPTO_1)) + + async def home_index(*_args, **_kwargs): + return 0 + + async def set_mode(*_args, **_kwargs): + return None + + async def move_axis(_axis, target, velocity_ticks_per_second=None, **_kwargs): + del velocity_ticks_per_second + moves.append(target) + return target + + async def get_flags(): + return next(flags) + + with ( + patch.multiple( + celigo.dichroic_filter, + _home_to_encoder_index=home_index, + request_limit_flags=get_flags, + ), + patch.object(celigo.dichroic_filter.motor, "_set_mode", set_mode), + patch.object(Axis, "move_to_ticks", new=move_axis), + ): + self.assertEqual(await celigo.dichroic_filter.home(), 4020) + self.assertEqual(moves, [20, 2020, 4020]) + self.assertTrue(celigo.dichroic_filter.has_position_reference) + + +class TestCameraFrame(unittest.TestCase): + def test_statistics_and_focus_metric(self): + flat = CameraFrame(bytes([5] * 25), 5, 5, 8, 1.0, 0.0, 0.0) + sharp_data = bytearray([5] * 25) + sharp_data[12] = 250 + sharp = CameraFrame(bytes(sharp_data), 5, 5, 8, 1.0, 0.0, 0.0) + self.assertEqual(flat.statistics(), (5, 5, 5.0)) + self.assertEqual(flat.sharpness(sample_step=1), 0.0) + self.assertGreater(sharp.sharpness(sample_step=1), 0.0) + + def test_dependency_free_png_encoding(self): + frame = CameraFrame(bytes((0, 127, 255, 64)), 2, 2, 8, 1.0, 0.0, 0.0) + encoded = frame.to_png_bytes() + + self.assertEqual(encoded[:8], b"\x89PNG\r\n\x1a\n") + ihdr_length = struct.unpack(">I", encoded[8:12])[0] + self.assertEqual(encoded[12:16], b"IHDR") + self.assertEqual( + struct.unpack(">IIBBBBB", encoded[16 : 16 + ihdr_length]), (2, 2, 8, 0, 0, 0, 0) + ) + idat_offset = 16 + ihdr_length + 4 + idat_length = struct.unpack(">I", encoded[idat_offset : idat_offset + 4])[0] + self.assertEqual(encoded[idat_offset + 4 : idat_offset + 8], b"IDAT") + compressed = encoded[idat_offset + 8 : idat_offset + 8 + idat_length] + self.assertEqual(zlib.decompress(compressed), b"\x00\x00\x7f\x00\xff\x40") + + thumbnail = CameraFrame(bytes(range(24)), 6, 4, 8, 1.0, 0.0, 0.0).to_png_bytes(maximum_size=3) + self.assertEqual(struct.unpack(">II", thumbnail[16:24]), (3, 2)) + + +class _FakeLucamLibrary: + def __init__(self): + self.exposure = 5.0 + self.gain = 1.0 + self.closed = False + self.frame_format = { + "x_offset": 0, + "y_offset": 0, + "width": 4, + "height": 3, + "pixel_format": 0, + "subsample_x": 1, + "flags_x": 0, + "subsample_y": 1, + "flags_y": 0, + } + self.stream_operations = [] + + def __getitem__(self, name): + return { + "LucamCameraOpen": self.LucamCameraOpen, + "LucamCameraClose": self.LucamCameraClose, + "LucamStreamVideoControl": self.LucamStreamVideoControl, + "LucamGetFormat": self.LucamGetFormat, + "LucamSetFormat": self.LucamSetFormat, + "LucamTakeVideo": self.LucamTakeVideo, + "LucamGetProperty": self.LucamGetProperty, + "LucamSetProperty": self.LucamSetProperty, + "LucamGetLastErrorForCamera": self.LucamGetLastErrorForCamera, + }[name] + + def LucamCameraOpen(self, _index): + return 1 + + def LucamCameraClose(self, _handle): + self.closed = True + return 1 + + def LucamStreamVideoControl(self, _handle, _operation, _unused): + self.stream_operations.append(_operation) + return 1 + + def LucamGetFormat(self, _handle, frame_format, frame_rate): + frame_format._obj.x_offset = self.frame_format["x_offset"] + frame_format._obj.y_offset = self.frame_format["y_offset"] + frame_format._obj.width = self.frame_format["width"] + frame_format._obj.height = self.frame_format["height"] + frame_format._obj.pixel_format = self.frame_format["pixel_format"] + frame_format._obj.subsample_x = self.frame_format["subsample_x"] + frame_format._obj.flags_x = self.frame_format["flags_x"] + frame_format._obj.subsample_y = self.frame_format["subsample_y"] + frame_format._obj.flags_y = self.frame_format["flags_y"] + frame_rate._obj.value = 10.0 + return 1 + + def LucamSetFormat(self, _handle, frame_format, _frame_rate): + self.frame_format = { + "x_offset": frame_format._obj.x_offset, + "y_offset": frame_format._obj.y_offset, + "width": frame_format._obj.width, + "height": frame_format._obj.height, + "pixel_format": frame_format._obj.pixel_format, + "subsample_x": frame_format._obj.subsample_x, + "flags_x": frame_format._obj.flags_x, + "subsample_y": frame_format._obj.subsample_y, + "flags_y": frame_format._obj.flags_y, + } + return 1 + + def LucamTakeVideo(self, _handle, _count, buffer): + ctypes.memset(buffer, 7, 12) + return 1 + + def LucamGetProperty(self, _handle, property_id, value, _flags): + value._obj.value = self.exposure if property_id == 20 else self.gain + return 1 + + def LucamSetProperty(self, _handle, property_id, value, _flags): + if property_id == 20: + self.exposure = value.value + else: + self.gain = value.value + return 1 + + def LucamGetLastErrorForCamera(self, _handle): + return 0 + + +class _SignatureRecordingFunction: + def __init__(self, function): + self._function = function + self.restype = None + self.argtypes = None + + def __call__(self, *args): + return self._function(*args) + + +class TestLumeneraCamera(unittest.IsolatedAsyncioTestCase): + def test_sdk_signature_is_bound_to_the_called_attribute(self): + library = _FakeLucamLibrary() + camera_open = _SignatureRecordingFunction(library.LucamCameraOpen) + camera = LumeneraCamera(library=library) + + with patch.object(library, "LucamCameraOpen", camera_open): + camera._load_library() + + self.assertIs(camera_open.restype, ctypes.c_void_p) + self.assertEqual(camera_open.argtypes, [ctypes.c_uint32]) + + async def test_centered_roi_is_set_read_back_and_stream_restarted(self): + library = _FakeLucamLibrary() + camera = LumeneraCamera(library=library) + await camera.setup() + self.assertEqual(await camera.set_frame_format(2, 1), (2, 1)) + self.assertEqual((camera.x_offset, camera.y_offset), (1, 1)) + self.assertEqual(library.stream_operations, [1, 0, 1]) + await camera.stop() + + async def test_sdk_capture_is_packaged(self): + library = _FakeLucamLibrary() + camera = LumeneraCamera(library=library) + await camera.setup() + await camera.set_exposure(2.5) + frame = await camera.capture(flush_frames=0) + self.assertEqual((frame.width, frame.height, frame.bit_depth), (4, 3, 8)) + self.assertEqual(frame.data, bytes([7] * 12)) + self.assertAlmostEqual(frame.exposure_ms, 2.5) + await camera.stop() + self.assertTrue(library.closed) + self.assertIsNone(camera._executor) + + async def test_capture_sleeps_only_between_flushed_frames(self): + library = _FakeLucamLibrary() + camera = LumeneraCamera(library=library) + await camera.setup() + with patch("pylabrobot.revvity.celigo.camera.time.sleep") as sleep: + await camera.capture(flush_frames=2) + self.assertEqual(sleep.call_count, 2) + await camera.stop() + + async def test_unsupported_pixel_format_closes_camera(self): + library = _FakeLucamLibrary() + + def unsupported(_handle, frame_format, frame_rate): + frame_format._obj.width = 4 + frame_format._obj.height = 3 + frame_format._obj.pixel_format = 99 + frame_rate._obj.value = 10.0 + return 1 + + camera = LumeneraCamera(library=library) + with ( + patch.object(library, "LucamGetFormat", unsupported), + self.assertRaisesRegex(Exception, "Unsupported Lumenera pixel format"), + ): + await camera.setup() + self.assertTrue(library.closed) + self.assertFalse(camera.is_open) + self.assertIsNone(camera._executor) + + async def test_timed_out_setup_is_poisoned_and_deferred_close_cannot_reopen(self): + started = threading.Event() + release = threading.Event() + library = _FakeLucamLibrary() + + def blocking_open(_index): + started.set() + release.wait() + return 1 + + camera = LumeneraCamera(library=library, sdk_call_timeout=0.01) + with ( + patch.object(library, "LucamCameraOpen", blocking_open), + self.assertRaisesRegex(CameraError, "close is queued"), + ): + await camera.setup() + self.assertTrue(started.is_set()) + self.assertFalse(camera.is_open) + with self.assertRaisesRegex(CameraError, "poisoned"): + await camera.stop() + release.set() + for _ in range(100): + cleanup = camera._pending_cleanup + if cleanup is not None and cleanup.done(): + break + await asyncio.sleep(0.001) + self.assertTrue(library.closed) + self.assertFalse(camera.is_open) + self.assertIsNone(camera._executor) + + +class TestGalvoReliability(unittest.IsolatedAsyncioTestCase): + async def test_calibration_always_waits_and_reports_controller_status(self): + celigo = make_celigo() + commands = [] + + async def send_command(opcode, payload=b"", retries=3): + del retries + commands.append((opcode, payload)) + return struct.pack(">H", 0) + + with patch.object(celigo, "send_command", send_command): + self.assertTrue(await celigo.galvo.calibrate("x", timeout=0.9)) + self.assertEqual(commands[0][0], _CMD_CALIBRATE_GALVO) + self.assertEqual(struct.unpack(">HHH", commands[0][1]), (0, 900, 1)) + + async def test_center_filter_offset_inversion_and_settle_payload(self): + celigo = make_celigo() + celigo.config.hardware = CeligoHardwareConfig( + x_galvo=make_galvo_config( + enabled=True, + min_voltage=0, + max_voltage=10, + invert_voltage=True, + ), + y_galvo=make_galvo_config(enabled=True, min_voltage=0, max_voltage=10), + ) + celigo.config.magnification = 3 + celigo.reply_timeout = 2.0 + axis_x = GalvoAxisOpticalCalibration( + magnifications={3: GalvoMagnificationCalibration(5.0, 6.5)}, + logical_filter_offsets={2: 0.2}, + laser_center_voltage=0.0, + uv_laser_center_voltage=0.0, + ) + axis_y = GalvoAxisOpticalCalibration( + magnifications={3: GalvoMagnificationCalibration(4.9, 6.4)}, + logical_filter_offsets={2: -0.1}, + laser_center_voltage=0.0, + uv_laser_center_voltage=0.0, + ) + celigo.config.galvo_optical_calibration = GalvoOpticalCalibration(axis_x, axis_y) + celigo.config.galvo_calibrations = {} + transactions = [] + transaction_timeouts = [] + + async def transact(opcode, payload=b"", retries=3, reply_timeout=None): + del retries + transactions.append((opcode, payload)) + transaction_timeouts.append(reply_timeout) + return b"\x00\x00" + + with patch.object(celigo, "send_command", transact): + targets = celigo.galvo.voltages_for_offset(2) + self.assertAlmostEqual(targets[0], 5.2) + self.assertAlmostEqual(targets[1], 4.8) + raw = await celigo.galvo.move_single("x", 5.2) + self.assertEqual(raw, -5.2) + _index, _dac, wait, timeout = struct.unpack(">HiHH", transactions[0][1]) + self.assertEqual((wait, timeout), (1, 6000)) + self.assertEqual(transaction_timeouts, [7.0]) + self.assertEqual(celigo.reply_timeout, 2.0) + with self.assertRaisesRegex(CeligoError, "outside configured range"): + await celigo.galvo.move_single("x", 10.1) + + async def test_move_both_starts_both_axes_before_polling_and_applies_configured_delay(self): + celigo = make_celigo() + celigo.config.hardware = CeligoHardwareConfig( + x_galvo=make_galvo_config( + enabled=True, + min_voltage=-10, + max_voltage=10, + big_move_delay=0.01, + ), + y_galvo=make_galvo_config( + enabled=True, + min_voltage=-10, + max_voltage=10, + big_move_delay=0.02, + ), + ) + transactions = [] + status_requests = 0 + + async def send_command(opcode, payload=b"", **_kwargs): + transactions.append((opcode, payload)) + return b"" + + async def request_status(): + nonlocal status_requests + status_requests += 1 + return _galvo_controller_status( + fire_table_size=0, + points_loaded=0, + fire_table_index=0, + ) + + with ( + patch.object(celigo, "send_command", send_command), + patch.object(celigo.galvo, "request_controller_status", request_status), + patch("pylabrobot.revvity.celigo.galvo.asyncio.sleep", new_callable=AsyncMock) as sleep, + ): + self.assertEqual(await celigo.galvo.move_both(1.0, 2.0), (1.0, 2.0)) + + self.assertEqual([opcode for opcode, _ in transactions], [_CMD_MOVE_GALVO] * 2) + self.assertEqual( + [struct.unpack(">HiHH", payload)[::2] for _, payload in transactions], + [(0, 0), (1, 0)], + ) + self.assertEqual(status_requests, 1) + sleep.assert_awaited_once_with(0.02) + + +class _FocusCamera: + def __init__(self): + self.is_open = True + self.width = 5 + self.height = 5 + self.exposure_ms = 1.0 + self.gain = 0.0 + self.z = 0 + + async def setup(self): + self.is_open = True + + async def stop(self): + self.is_open = False + + async def set_exposure(self, exposure_ms): + self.exposure_ms = exposure_ms + return exposure_ms + + async def set_gain(self, gain): + self.gain = gain + return gain + + async def capture(self, flush_frames=2): + del flush_frames + value = 255 - abs(self.z - 12) + return CameraFrame(bytes([value] * 25), 5, 5, 8, self.exposure_ms, self.gain, 0.0) + + +class TestHostAutofocus(unittest.IsolatedAsyncioTestCase): + async def test_zero_coarse_step_is_rejected_before_reading_hardware(self): + celigo = make_celigo( + hardware=CeligoHardwareConfig( + z_axis=make_linear_axis_config( + axis_index=3, + min_position=0, + max_position=20, + mm_per_encoder_tick=1, + ) + ) + ) + with self.assertRaisesRegex(ValueError, "coarse_step_ticks positive"): + await celigo.autofocus(coarse_step_ticks=0) + + async def test_finds_best_z_and_settles_there(self): + camera = _FocusCamera() + celigo = make_celigo( + hardware=CeligoHardwareConfig( + z_axis=make_linear_axis_config( + axis_index=3, + min_position=0, + max_position=20, + mm_per_encoder_tick=1, + ) + ) + ) + celigo.config.calibration = make_calibration_config( + image_width_pixels=5, + image_height_pixels=5, + ) + + async def request_encoder_ticks(): + return 10 + + async def move_z(target_encoder_ticks): + camera.z = target_encoder_ticks + return target_encoder_ticks + + with ( + patch.object(celigo, "camera", camera), + patch.multiple( + celigo.z_axis, + request_encoder_ticks=request_encoder_ticks, + move_to_ticks=move_z, + ), + ): + result = await celigo.autofocus( + center_z_ticks=10, + span_ticks=4, + coarse_step_ticks=2, + fine_step_ticks=1, + evaluator=lambda frame: frame.data[0], + settle_seconds=0, + ) + self.assertEqual(result.z_ticks, 12) + self.assertEqual(camera.z, 12) + + async def test_default_acquire_applies_channel_z_offset(self): + camera = _FocusCamera() + celigo = make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config( + axis_index=1, + min_position=0, + max_position=3, + mm_per_encoder_tick=1, + ), + y_axis=make_linear_axis_config( + axis_index=2, + min_position=0, + max_position=3, + mm_per_encoder_tick=1, + ), + z_axis=make_linear_axis_config( + axis_index=3, + min_position=0, + max_position=3, + mm_per_encoder_tick=0.01, + ), + ) + ) + celigo.config.calibration = make_calibration_config( + calibrated_z_position=1.0, + image_width_pixels=5, + image_height_pixels=5, + ) + celigo.config.channels_by_magnification[celigo.config.magnification] = { + "brightfield": IlluminationChannelConfig( + "brightfield", + "Brightfield", + 1, + None, + 0, + "bf", + False, + 0.0, + 1.0, + 1.0, + ), + "green": IlluminationChannelConfig( + "green", + "Green", + 2, + 1, + 0, + "fl", + True, + 0.1, + 1.0, + 1.0, + ), + } + celigo.current_channel = "brightfield" + moved_z = [] + + async def move_to_well(_well, retract_z=False): + self.assertTrue(retract_z) + return 10, 20 + + async def select(channel, **_kwargs): + celigo.current_channel = channel + + async def move_z(position_mm): + moved_z.append(position_mm) + return position_mm + + async def move_both(_x, _y): + return 0.0, 0.0 + + async def no_op(*_args, **_kwargs): + return None + + with ( + patch.multiple( + celigo, + camera=camera, + move_to_well=move_to_well, + select_channel=select, + set_illumination_enabled=no_op, + turn_off_illumination=no_op, + ), + patch.object(celigo.z_axis, "move_to", move_z), + patch.object(celigo.galvo, "voltages_for_offset", return_value=(0.0, 0.0)), + patch.object(celigo.galvo, "move_both", move_both), + ): + result = await celigo.acquire("A1", "green") + self.assertEqual(moved_z, [1.1]) + self.assertEqual(result.z_mm, 1.1) + + async def test_default_acquire_uses_calibrated_z_and_channel_offset(self): + camera = _FocusCamera() + celigo = make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config( + axis_index=1, + min_position=0, + max_position=3, + mm_per_encoder_tick=1, + ), + y_axis=make_linear_axis_config( + axis_index=2, + min_position=0, + max_position=3, + mm_per_encoder_tick=1, + ), + z_axis=make_linear_axis_config( + axis_index=3, + min_position=0, + max_position=3, + mm_per_encoder_tick=0.01, + ), + ) + ) + celigo.config.calibration = make_calibration_config( + calibrated_z_position=2.0, + image_width_pixels=5, + image_height_pixels=5, + ) + celigo.config.channels_by_magnification[celigo.config.magnification] = { + "green": IlluminationChannelConfig( + "green", + "Green", + 2, + 1, + 0, + "fl", + True, + 0.1, + 1.0, + 1.0, + ), + } + celigo.current_channel = None + moved_z = [] + + async def move_to_well(_well, retract_z=False): + self.assertTrue(retract_z) + return 10, 20 + + async def select(channel, **_kwargs): + celigo.current_channel = channel + + async def move_z(position_mm): + moved_z.append(position_mm) + return position_mm + + async def move_both(_x, _y): + return 0.0, 0.0 + + async def no_op(*_args, **_kwargs): + return None + + with ( + patch.multiple( + celigo, + camera=camera, + move_to_well=move_to_well, + select_channel=select, + set_illumination_enabled=no_op, + turn_off_illumination=no_op, + ), + patch.object(celigo.z_axis, "move_to", move_z), + patch.object(celigo.galvo, "voltages_for_offset", return_value=(0.0, 0.0)), + patch.object(celigo.galvo, "move_both", move_both), + ): + result = await celigo.acquire("A1", "green") + self.assertEqual(moved_z, [2.1]) + self.assertEqual(result.z_mm, 2.1) + + async def test_acquire_failure_extinguishes_illumination(self): + celigo = make_celigo() + extinguished = [] + + async def fail(**_kwargs): + raise CeligoError("simulated capture failure") + + async def turn_off_illumination(): + extinguished.append(True) + + with ( + patch.multiple( + celigo, + _acquire_field=fail, + turn_off_illumination=turn_off_illumination, + ), + self.assertRaisesRegex(CeligoError, "capture failure"), + ): + await celigo.acquire("A1", "brightfield") + self.assertEqual(extinguished, [True]) + + async def test_successful_acquire_extinguishes_illumination(self): + celigo = make_celigo() + result = object() + extinguished = [] + + async def acquire_field(**_kwargs): + return result + + async def turn_off_illumination(): + extinguished.append(True) + + with patch.multiple( + celigo, + _acquire_field=acquire_field, + turn_off_illumination=turn_off_illumination, + ): + self.assertIs(await celigo.acquire("A1", "brightfield"), result) + self.assertEqual(extinguished, [True]) + + +class TestCameraGeometry(unittest.IsolatedAsyncioTestCase): + def test_mismatched_calibrated_geometry_is_rejected(self): + celigo = make_celigo() + celigo.camera.width = 2464 + celigo.camera.height = 2056 + celigo.config.calibration = make_calibration_config( + image_width_pixels=2048, + image_height_pixels=2048, + ) + with self.assertRaisesRegex(CeligoError, "does not match calibrated"): + celigo._validate_camera_geometry() + + def test_short_frame_is_rejected_before_geometry_validation(self): + celigo = make_celigo() + frame = CameraFrame(b"\x00\x01\x02", 2, 2, 8, 1.0, 0.0, 0.0) + with self.assertRaisesRegex(CeligoError, "4 are required"): + celigo._validate_frame_geometry(frame) + + async def test_direct_camera_capture_allows_full_sensor_geometry(self): + celigo = make_celigo() + camera = _FocusCamera() + camera.width = 5 + camera.height = 5 + celigo.config.calibration = make_calibration_config( + image_width_pixels=4, + image_height_pixels=4, + ) + with patch.object(celigo, "camera", camera): + frame = await celigo.camera.capture(flush_frames=0) + self.assertEqual((frame.width, frame.height), (5, 5)) + with self.assertRaisesRegex(CeligoError, "does not match calibrated"): + await celigo.capture_frame(flush_frames=0) + + +class TestExternalCameraSignals(unittest.IsolatedAsyncioTestCase): + async def test_configured_signal_inversion(self): + celigo = make_celigo() + celigo.config.hardware = CeligoHardwareConfig( + external_camera_control=ExternalCameraControlConfig( + config_version=0, + enabled=True, + invert_busy=True, + invert_integration=True, + ) + ) + + async def diagnostic(operation): + return {4: 0, 5: 1}[operation] + + with patch.object(celigo, "_send_signal_diagnostic_command", diagnostic): + self.assertTrue(await celigo.request_is_camera_busy()) + self.assertFalse(await celigo.request_is_camera_integrating()) + + async def test_unavailable_signal_returns_none(self): + celigo = make_celigo() + celigo.config.hardware = CeligoHardwareConfig() + + async def diagnostic(_operation): + return 2 + + with patch.object(celigo, "_send_signal_diagnostic_command", diagnostic): + self.assertIsNone(await celigo.request_is_camera_busy()) + + +class TestLaserSafety(unittest.IsolatedAsyncioTestCase): + async def test_laser_is_disabled_by_default(self): + celigo = make_celigo() + self.assertIsInstance(celigo.laser, Laser) + self.assertFalse(celigo.laser.enabled) + with self.assertRaises(CeligoError): + await celigo.laser.fire(0, 1, 0) + + async def test_constructor_enables_owned_laser(self): + celigo = make_celigo(allow_laser=True) + self.assertTrue(celigo.laser.enabled) + + async def test_fire_converts_delay_seconds_to_controller_ticks(self): + celigo = make_celigo(allow_laser=True) + transactions = [] + + async def status(): + return ControllerStatus(0, 0) + + async def transact(opcode, payload=b"", retries=3): + del retries + transactions.append((opcode, payload)) + return b"" + + async def ready(**_kwargs): + return True + + with patch.multiple( + celigo, + request_controller_status=status, + send_command=transact, + wait_for_controller_ready=ready, + ): + await celigo.laser.fire(laser_index=1, shots=3, delay=0.00025) + + self.assertEqual(transactions[0][0], _CMD_FIRE_LASER) + self.assertEqual(struct.unpack(">Hii", transactions[0][1]), (1, 3, 25)) + + async def test_incomplete_fire_is_aborted(self): + celigo = make_celigo(allow_laser=True) + aborts = [] + + async def status(): + return ControllerStatus(0, 0) + + async def transact(_opcode, _payload=b"", retries=3): + del retries + return b"" + + async def not_ready(**_kwargs): + return False + + async def abort(): + aborts.append(True) + + with ( + patch.multiple( + celigo, + request_controller_status=status, + send_command=transact, + wait_for_controller_ready=not_ready, + abort_controller_operation=abort, + ), + self.assertRaisesRegex(TimeoutError, "did not complete"), + ): + await celigo.laser.fire(laser_index=0, shots=1) + + self.assertEqual(aborts, [True]) + + async def test_incomplete_grid_fire_is_aborted(self): + celigo = make_celigo(allow_laser=True) + celigo.config.hardware = CeligoHardwareConfig( + x_galvo=make_galvo_config(enabled=True, min_voltage=-10, max_voltage=10), + y_galvo=make_galvo_config(enabled=True, min_voltage=-10, max_voltage=10), + ) + aborts = [] + + async def status(): + return ControllerStatus(0, 0) + + async def transact(_opcode, _payload=b"", retries=3): + del retries + return b"" + + async def not_ready(**_kwargs): + return False + + async def abort(): + aborts.append(True) + + with ( + patch.multiple( + celigo, + request_controller_status=status, + send_command=transact, + wait_for_controller_ready=not_ready, + abort_controller_operation=abort, + ), + self.assertRaisesRegex(TimeoutError, "grid firing did not complete"), + ): + await celigo.laser.fire_grid( + 0, + (0.1, 0.1), + (1.0, 1.0), + (0.0, 0.0), + 1, + 1, + ) + + self.assertEqual(aborts, [True]) + + async def test_incomplete_targeted_fire_is_aborted(self): + celigo = make_celigo(allow_laser=True) + aborts = [] + + async def status(): + return ControllerStatus(0, 0) + + async def targeting_status(): + return _galvo_controller_status( + fire_table_size=32, + points_loaded=1, + fire_table_index=0, + ) + + async def load(_points, _center): + return None + + async def transact(_opcode, _payload=b"", retries=3): + del retries + return b"" + + async def not_ready(**_kwargs): + return False + + async def abort(): + aborts.append(True) + + with ( + patch.multiple( + celigo, + request_controller_status=status, + send_command=transact, + wait_for_controller_ready=not_ready, + abort_controller_operation=abort, + ), + patch.object(celigo.galvo, "request_controller_status", targeting_status), + patch.object(celigo.laser, "_load_firing_targets", load), + self.assertRaisesRegex(TimeoutError, "Targeted laser firing did not complete"), + ): + await celigo.laser.fire_targets( + [(0.0, 0.0)], + 0, + 1, + center_voltages=(0.0, 0.0), + ) + + self.assertEqual(aborts, [True]) + + async def test_uart_command_and_response_use_component_api(self): + celigo = make_celigo(allow_laser=True) + transactions = [] + + async def status(): + return ControllerStatus(0, 0) + + async def transact(opcode, payload=b"", retries=3): + del retries + transactions.append((opcode, payload)) + if opcode == _CMD_READ_LASER_COMM: + return struct.pack(">HH", 0, 3) + b"OK\x00" + return b"" + + with patch.multiple( + celigo, + request_controller_status=status, + send_command=transact, + ): + await celigo.laser.send_command("STATUS?") + self.assertEqual(await celigo.laser.request_uart_response(), "OK") + self.assertEqual( + transactions, + [ + (_CMD_SEND_LASER_COMM, b"STATUS?\x00"), + (_CMD_READ_LASER_COMM, b""), + ], + ) + + async def test_grid_payload_matches_vendor_layout(self): + celigo = make_celigo(allow_laser=True) + celigo.move_timeout = 1.0 + celigo.config.hardware = CeligoHardwareConfig( + x_galvo=make_galvo_config(enabled=True, min_voltage=-10, max_voltage=10), + y_galvo=make_galvo_config(enabled=True, min_voltage=-10, max_voltage=10), + ) + transactions = [] + + async def status(): + return ControllerStatus(0, 0) + + async def transact(opcode, payload=b"", retries=3): + del retries + transactions.append((opcode, payload)) + return b"" + + async def ready(timeout=5.0, poll=0.01): + del timeout, poll + return True + + with patch.multiple( + celigo, + request_controller_status=status, + send_command=transact, + wait_for_controller_ready=ready, + ): + await celigo.laser.fire_grid( + 0, + (0.1, 0.1), + (1.0, 1.0), + (0.0, 0.0), + 1, + 1, + delay_between_repeats=0.0025, + ) + self.assertEqual(transactions[0][0], _CMD_FIRE_GALVO_GRID) + self.assertEqual(len(transactions[0][1]), 32) + self.assertEqual(struct.unpack(">HHHHHHHiiiiH", transactions[0][1])[9], 250) + + async def test_target_table_applies_axis_inversion_to_explicit_center(self): + celigo = make_celigo(allow_laser=True) + celigo.config.hardware = CeligoHardwareConfig( + x_galvo=make_galvo_config( + enabled=True, + invert_voltage=True, + min_voltage=-10, + max_voltage=10, + ), + y_galvo=make_galvo_config( + enabled=True, + invert_voltage=False, + min_voltage=-10, + max_voltage=10, + ), + ) + transactions = [] + + async def status(): + return ControllerStatus(0, 0) + + async def transact(opcode, payload=b"", retries=3): + del retries + transactions.append((opcode, payload)) + return b"" + + async def ready(**_kwargs): + return True + + with patch.multiple( + celigo, + request_controller_status=status, + send_command=transact, + wait_for_controller_ready=ready, + ): + await celigo.laser._load_firing_targets([(0.1, -0.1)], (1.6, 1.5)) + self.assertEqual(transactions[0][0], _CMD_LOAD_FIRING_TABLE) + x_dac, y_dac = struct.unpack_from(">HH", transactions[0][1], 4) + self.assertAlmostEqual(dac_count_to_volts(x_dac), -1.7, places=3) + self.assertAlmostEqual(dac_count_to_volts(y_dac), 1.4, places=3) + + async def test_target_fire_rechecks_interlock_after_table_load(self): + celigo = make_celigo(allow_laser=True) + statuses = iter((ControllerStatus(0, 0), ControllerStatus(0x0004, 0))) + targeted = [] + + async def status(): + return next(statuses) + + async def load(_points, _center): + return None + + async def targeting_status(): + return _galvo_controller_status( + fire_table_size=32, + points_loaded=0, + fire_table_index=0, + ) + + async def transact(opcode, payload=b"", retries=3): + del payload, retries + if opcode == _CMD_TARGETED_FIRE: + targeted.append(opcode) + return b"" + + with ( + patch.multiple( + celigo, + request_controller_status=status, + send_command=transact, + ), + patch.object(celigo.galvo, "request_controller_status", targeting_status), + patch.object(celigo.laser, "_load_firing_targets", load), + self.assertRaises(CeligoError), + ): + await celigo.laser.fire_targets( + [(0.0, 0.0)], + 0, + 1, + center_voltages=(0.0, 0.0), + ) + self.assertEqual(targeted, []) + + async def test_empty_target_list_is_rejected_before_status_io(self): + celigo = make_celigo(allow_laser=True) + with self.assertRaisesRegex(ValueError, "must not be empty"): + await celigo.laser.fire_targets([], 0, 1) + + async def test_target_fire_uses_calibrated_center_for_selected_laser(self): + celigo = make_celigo(allow_laser=True) + celigo.move_timeout = 1 + celigo.config.galvo_optical_calibration = GalvoOpticalCalibration( + GalvoAxisOpticalCalibration({}, {}, laser_center_voltage=1.6, uv_laser_center_voltage=0.2), + GalvoAxisOpticalCalibration({}, {}, laser_center_voltage=1.5, uv_laser_center_voltage=0.1), + ) + centers = [] + targeting_statuses = iter( + ( + _galvo_controller_status( + fire_table_size=32, + points_loaded=0, + fire_table_index=0, + ), + _galvo_controller_status( + fire_table_size=32, + points_loaded=1, + fire_table_index=1, + ), + ) + ) + + async def status(): + return ControllerStatus(0, 0) + + async def load(_points, center): + centers.append(center) + + async def targeting_status(): + return next(targeting_statuses) + + async def transact(_opcode, _payload=b"", retries=3): + del retries + return b"" + + async def ready(**_kwargs): + return True + + with ( + patch.multiple( + celigo, + request_controller_status=status, + send_command=transact, + wait_for_controller_ready=ready, + ), + patch.object(celigo.galvo, "request_controller_status", targeting_status), + patch.object(celigo.laser, "_load_firing_targets", load), + ): + await celigo.laser.fire_targets([(0.0, 0.0)], 1, 1) + self.assertEqual(centers, [(0.2, 0.1)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/revvity/celigo/tests/galvo_conversion_tests.py b/pylabrobot/revvity/celigo/tests/galvo_conversion_tests.py new file mode 100644 index 00000000000..9e91047a1c0 --- /dev/null +++ b/pylabrobot/revvity/celigo/tests/galvo_conversion_tests.py @@ -0,0 +1,74 @@ +"""Tests for calibrated Galvo field-offset conversion.""" + +import unittest + +from pylabrobot.revvity.celigo.config import ( + Calibrated2DPolynomialTransform, + GalvoAxisOpticalCalibration, + GalvoMagnificationCalibration, + GalvoOpticalCalibration, +) +from pylabrobot.revvity.celigo.errors import CeligoError +from pylabrobot.revvity.celigo.galvo import volts_to_dac_count +from pylabrobot.revvity.celigo.tests.helpers import make_celigo + + +class TestGalvoPolynomial(unittest.TestCase): + @staticmethod + def _voltages_for_offset(reverse_terms, offset_mm): + celigo = make_celigo() + celigo.config.magnification = 3 + center = GalvoMagnificationCalibration(center_voltage=0.0, frame_size_volts=0.0) + celigo.config.galvo_optical_calibration = GalvoOpticalCalibration( + x=GalvoAxisOpticalCalibration({3: center}, {}, 0.0, 0.0), + y=GalvoAxisOpticalCalibration({3: center}, {}, 0.0, 0.0), + ) + celigo.config.galvo_calibrations = { + 2: Calibrated2DPolynomialTransform(forward={}, reverse=reverse_terms, order=3) + } + return celigo.galvo.voltages_for_offset(2, offset_mm) + + def test_mm_to_volts_inverse_linear(self): + x_voltage, y_voltage = self._voltages_for_offset( + {"LinearXTerm": (1.0 / 1.3, 0.0), "LinearYTerm": (0.0, 1.0 / 1.3)}, + (1.3, 2.6), + ) + self.assertAlmostEqual(x_voltage, -1.0) + self.assertAlmostEqual(y_voltage, 2.0) + + def test_offset_and_cross_terms(self): + x_voltage, y_voltage = self._voltages_for_offset( + { + "OffsetTerm": (0.5, -0.5), + "LinearXTerm": (2.0, 0.0), + "LinearYTerm": (0.0, 3.0), + "CrossTerm": (0.1, 0.0), + }, + (2.0, 1.0), + ) + self.assertAlmostEqual(x_voltage, -3.7) + self.assertAlmostEqual(y_voltage, 2.5) + + def test_cubic_terms(self): + x_voltage, y_voltage = self._voltages_for_offset( + {"CubicXTerm": (1.0, 0.0), "QuadraticXLinearYTerm": (0.0, 1.0)}, + (2.0, 3.0), + ) + self.assertAlmostEqual(x_voltage, -8.0) + self.assertAlmostEqual(y_voltage, 12.0) + + def test_unknown_polynomial_term_is_rejected(self): + with self.assertRaisesRegex(CeligoError, "Unsupported.*MysteryTerm"): + self._voltages_for_offset({"MysteryTerm": (1.0, 1.0)}, (1.0, 1.0)) + + def test_non_finite_polynomial_coefficient_is_rejected(self): + with self.assertRaisesRegex(CeligoError, "LinearXTerm.*not finite"): + self._voltages_for_offset({"LinearXTerm": (float("nan"), 0.0)}, (1.0, 1.0)) + + def test_dac_conversion_rejects_out_of_range_voltage(self): + with self.assertRaisesRegex(ValueError, "range -10..10 V"): + volts_to_dac_count(10.01) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/revvity/celigo/tests/helpers.py b/pylabrobot/revvity/celigo/tests/helpers.py new file mode 100644 index 00000000000..6d8ae9d6056 --- /dev/null +++ b/pylabrobot/revvity/celigo/tests/helpers.py @@ -0,0 +1,289 @@ +"""Shared constructor-based test fixtures for the Celigo driver.""" + +from dataclasses import replace +from typing import Any, Optional, Tuple +from unittest.mock import patch + +from pylabrobot.revvity.celigo.camera import CameraFrame +from pylabrobot.revvity.celigo.celigo import Celigo +from pylabrobot.revvity.celigo.config import ( + CalibrationConfig, + CeligoConfig, + CeligoHardwareConfig, + FilterWheelConfig, + GalvoAxisOpticalCalibration, + GalvoConfig, + GalvoMagnificationCalibration, + GalvoOpticalCalibration, + HardwareDefaultConfig, + LinearAxisConfig, + NavigationConfig, +) + + +class FakeCamera: + """In-memory camera that satisfies the lifecycle expected by ``Celigo`` tests.""" + + def __init__(self, sdk_library: Optional[str] = None) -> None: + self.sdk_library = sdk_library + self.is_open = False + self.width = 1 + self.height = 1 + self.bit_depth = 8 + self.x_offset = 0 + self.y_offset = 0 + self.frame_rate = 1.0 + self.exposure_ms = 1.0 + self.gain = 0.0 + + async def setup(self) -> None: + self.is_open = True + + async def stop(self) -> None: + self.is_open = False + + async def set_exposure(self, exposure_ms: float) -> float: + self.exposure_ms = exposure_ms + return exposure_ms + + async def set_gain(self, gain: float) -> float: + self.gain = gain + return gain + + async def set_frame_format( + self, + width: int, + height: int, + x_offset: Optional[int] = None, + y_offset: Optional[int] = None, + ) -> Tuple[int, int]: + self.width = width + self.height = height + if x_offset is not None: + self.x_offset = x_offset + if y_offset is not None: + self.y_offset = y_offset + return width, height + + async def capture(self, flush_frames: int = 2) -> CameraFrame: + del flush_frames + return CameraFrame( + data=bytes(self.width * self.height), + width=self.width, + height=self.height, + bit_depth=self.bit_depth, + exposure_ms=self.exposure_ms, + gain=self.gain, + captured_at=0.0, + ) + + +def make_linear_axis_config(**changes: Any) -> LinearAxisConfig: + """Build a complete linear-axis config for a focused test.""" + config = LinearAxisConfig( + motion_name="Test linear axis", + config_version=0, + motor_type=0, + comm_index=0, + controller_index=0, + axis_index=1, + enabled=True, + max_velocity=0.0, + max_acceleration=0.0, + max_deceleration=0.0, + max_s_acceleration=0, + moderate_acceleration=0.0, + minimum_acceleration=0.0, + moderate_s_acceleration=0, + minimum_s_acceleration=0, + s_curve_support=False, + home_type="", + homing_velocity=0.0, + index_velocity=0.0, + homing_short_move=0, + home_offset=0.0, + positive_limit=False, + negative_limit=False, + limit_polarity=0, + invert_axis_direction=False, + default_positive_direction=False, + moving_current_percentage=0, + holding_current_percentage=0, + loading_current_percentage=0, + moving_overload_limit=0, + mode_enable_limits=False, + mode_enable_step_and_direction=False, + mode_enable_position_correction=False, + mode_enable_motor_slave_to_encoder=False, + coarse_position_error_window=0, + fine_position_error_window=0, + gain=0, + encoder_to_motor_tick_ratio=0.0, + backlash_compensation=0, + motor_response_time=0, + min_position=0.0, + max_position=0.0, + mm_per_encoder_tick=0.0, + ) + return replace(config, **changes) + + +def make_filter_wheel_config(**changes: Any) -> FilterWheelConfig: + """Build a complete filter-wheel config for a focused test.""" + config = FilterWheelConfig( + motion_name="Test filter wheel", + config_version=0, + motor_type=0, + comm_index=0, + controller_index=0, + axis_index=1, + enabled=True, + max_velocity=0.0, + max_acceleration=0.0, + max_deceleration=0.0, + max_s_acceleration=0, + moderate_acceleration=0.0, + minimum_acceleration=0.0, + moderate_s_acceleration=0, + minimum_s_acceleration=0, + s_curve_support=False, + home_type="", + homing_velocity=0.0, + index_velocity=0.0, + homing_short_move=0, + home_offset=0.0, + positive_limit=False, + negative_limit=False, + limit_polarity=0, + invert_axis_direction=False, + default_positive_direction=False, + moving_current_percentage=0, + holding_current_percentage=0, + loading_current_percentage=0, + moving_overload_limit=0, + mode_enable_limits=False, + mode_enable_step_and_direction=False, + mode_enable_position_correction=False, + mode_enable_motor_slave_to_encoder=False, + coarse_position_error_window=0, + fine_position_error_window=0, + gain=0, + encoder_to_motor_tick_ratio=0.0, + backlash_compensation=0, + motor_response_time=0, + encoder_ticks_per_revolution=0, + number_of_filters=0, + filter_map=[], + ) + return replace(config, **changes) + + +def make_galvo_config(**changes: Any) -> GalvoConfig: + """Build a complete galvo config for a focused test.""" + config = GalvoConfig( + config_version=0, + controller_index=0, + position_error_window=0, + velocity_error_window=0, + big_move_delay=0.0, + min_voltage=0.0, + max_voltage=0.0, + invert_voltage=False, + enabled=True, + ) + return replace(config, **changes) + + +def make_calibration_config(**changes: Any) -> CalibrationConfig: + """Build a complete optical/stage calibration for a focused test.""" + config = CalibrationConfig( + microns_per_pixel_x=1.0, + microns_per_pixel_y=1.0, + image_width_pixels=2048, + image_height_pixels=2048, + image_to_stage_theta_radians=0.0, + galvo_to_stage_theta_radians=0.0, + calibrated_plate_corner_x=0.0, + calibrated_plate_corner_y=0.0, + calibrated_plate_to_stage_theta_radians=0.0, + stage_x_scale=1.0, + stage_y_scale=1.0, + stage_shear=0.0, + stage_x_shear_offset=0.0, + stage_y_shear_offset=0.0, + calibrated_z_position=0.0, + calibrated_z_glass_plate_delta=0.0, + z_plane_x_coeff=0.0, + z_plane_y_coeff=0.0, + ) + return replace(config, **changes) + + +def make_hardware_default_config(**changes: Any) -> HardwareDefaultConfig: + """Build a complete hardware-default calibration for a focused test.""" + config = HardwareDefaultConfig( + default_calibrated_z=0.0, + default_plate_x_corner_stage_coordinate=0.0, + default_plate_y_corner_stage_coordinate=0.0, + default_x_field_of_view_mm=0.0, + default_y_field_of_view_mm=0.0, + default_x_galvo_mm_per_volt=0.0, + default_y_galvo_mm_per_volt=0.0, + ) + return replace(config, **changes) + + +def make_navigation_config(**changes: Any) -> NavigationConfig: + """Build a complete navigation config for a focused test.""" + config = NavigationConfig( + frame_overlap_x_mm=0.0, + frame_overlap_y_mm=0.0, + max_galvo_deflection_x_mm=0.0, + max_galvo_deflection_y_mm=0.0, + ) + return replace(config, **changes) + + +def make_test_config() -> CeligoConfig: + """Build a complete configuration whose individual hardware components are absent.""" + magnifications = { + value: GalvoMagnificationCalibration(center_voltage=0.0, frame_size_volts=0.0) + for value in (3, 5, 10, 20) + } + optical_axis = GalvoAxisOpticalCalibration( + magnifications=magnifications, + logical_filter_offsets={}, + laser_center_voltage=0.0, + uv_laser_center_voltage=0.0, + ) + return CeligoConfig( + hardware=CeligoHardwareConfig(), + channel_descriptors=[], + channels_by_magnification={magnification: {} for magnification in (3, 5, 10, 20)}, + calibration=make_calibration_config(), + hardware_defaults=make_hardware_default_config(), + galvo_calibrations={}, + galvo_optical_calibration=GalvoOpticalCalibration( + x=optical_axis, + y=optical_axis, + ), + navigation=make_navigation_config(), + ) + + +def make_celigo( + *, + config: Optional[CeligoConfig] = None, + hardware: Optional[CeligoHardwareConfig] = None, + allow_laser: bool = False, +) -> Celigo: + """Construct a hardware-free ``Celigo`` for tests.""" + config = make_test_config() if config is None else config + if hardware is not None: + config.hardware = hardware + camera = FakeCamera() + with ( + patch("pylabrobot.revvity.celigo.celigo.FTDI", return_value=object()), + patch("pylabrobot.revvity.celigo.celigo.LumeneraCamera", return_value=camera), + ): + return Celigo(config=config, allow_laser=allow_laser) diff --git a/pylabrobot/revvity/celigo/tests/navigation_tests.py b/pylabrobot/revvity/celigo/tests/navigation_tests.py new file mode 100644 index 00000000000..9283b46af5e --- /dev/null +++ b/pylabrobot/revvity/celigo/tests/navigation_tests.py @@ -0,0 +1,145 @@ +"""Tests for plate/well navigation and the FOV galvo grid.""" + +import unittest + +from pylabrobot.resources.corning.plates import cor_96_wellplate_360uL_Fb +from pylabrobot.resources.tecan.plates import DeepWell_Greiner_1536_Well +from pylabrobot.resources.vwr.plates import VWR_1_troughplate_195000uL_Ub +from pylabrobot.revvity.celigo.coordinates import CoordinateSystems +from pylabrobot.revvity.celigo.navigation import ( + effective_fov_mm, + fields_of_view_per_field_of_reference, + galvo_field_of_view_offsets_mm, + well_to_sample_mm, + well_to_stage_mm, +) +from pylabrobot.revvity.celigo.tests.helpers import ( + make_calibration_config, + make_hardware_default_config, + make_navigation_config, +) + + +def _coords(): + calib = make_calibration_config( + microns_per_pixel_x=1.05456, + microns_per_pixel_y=1.05444, + image_width_pixels=2048, + image_height_pixels=2048, + stage_x_scale=1.0, + stage_y_scale=1.0, + ) + hw = make_hardware_default_config( + default_plate_x_corner_stage_coordinate=2.159, + default_plate_y_corner_stage_coordinate=3.492, + ) + return calib, hw, CoordinateSystems.from_config(calib, hw) + + +class TestPlateNavigation(unittest.TestCase): + def test_well_sample_position_uses_plate_geometry_without_stage_calibration(self): + plate = cor_96_wellplate_360uL_Fb(name="imaging_plate") + x, y = well_to_sample_mm(plate, "A1") + self.assertAlmostEqual(x, 14.3) + self.assertAlmostEqual(y, 11.28) + + def test_plr_plate_uses_its_resource_geometry(self): + _, _, cs = _coords() + plate = cor_96_wellplate_360uL_Fb(name="imaging_plate") + a1 = well_to_stage_mm(plate, "A1", cs) + a2 = well_to_stage_mm(plate, "A2", cs) + b1 = well_to_stage_mm(plate, "B1", cs) + self.assertAlmostEqual(a1[0], 16.459) + self.assertAlmostEqual(a1[1], 14.772) + self.assertAlmostEqual(a2[0] - a1[0], 9.0) + self.assertAlmostEqual(b1[1] - a1[1], 9.0) + + def test_out_of_range(self): + _, _, cs = _coords() + plate = cor_96_wellplate_360uL_Fb(name="imaging_plate") + with self.assertRaises(ValueError): + well_to_stage_mm(plate, "I1", cs) + + def test_single_well_plate_uses_its_actual_well_center(self): + _, _, cs = _coords() + plate = VWR_1_troughplate_195000uL_Ub(name="reservoir") + x, y = well_to_stage_mm(plate, "A1", cs) + well = plate.get_well("A1") + location = well.location + self.assertIsNotNone(location) + assert location is not None + self.assertAlmostEqual(x, location.x + well.get_size_x() / 2 + 2.159) + self.assertAlmostEqual( + y, + plate.get_size_y() - (location.y + well.get_size_y() / 2) + 3.492, + ) + + def test_well_names_beyond_z_use_plr_lookup(self): + _, _, cs = _coords() + plate = DeepWell_Greiner_1536_Well(name="plate") + aa1 = well_to_stage_mm(plate, "AA1", cs) + well = plate.get_well("AA1") + location = well.location + self.assertIsNotNone(location) + assert location is not None + self.assertAlmostEqual(aa1[0], location.x + well.get_size_x() / 2 + 2.159) + + +class TestWellToStage(unittest.TestCase): + def test_a1_stage_position(self): + _, _, cs = _coords() + plate = cor_96_wellplate_360uL_Fb(name="imaging_plate") + x, y = well_to_stage_mm(plate, "A1", cs) + # this config has no calibrated corner offset, so stage = sample + default corner: + self.assertAlmostEqual(x, 16.459) + self.assertAlmostEqual(y, 14.772) + + def test_adjacent_wells_differ_by_pitch(self): + _, _, cs = _coords() + plate = cor_96_wellplate_360uL_Fb(name="imaging_plate") + a1 = well_to_stage_mm(plate, "A1", cs) + a2 = well_to_stage_mm(plate, "A2", cs) + b1 = well_to_stage_mm(plate, "B1", cs) + self.assertAlmostEqual(a2[0] - a1[0], 9.0) + self.assertAlmostEqual(b1[1] - a1[1], 9.0) + + +class TestFovGrid(unittest.TestCase): + def setUp(self): + self.calib, self.hw, self.cs = _coords() + self.nav = make_navigation_config( + frame_overlap_x_mm=0.1, + frame_overlap_y_mm=0.1, + max_galvo_deflection_x_mm=4.5, + max_galvo_deflection_y_mm=4.5, + ) + + def test_effective_fov(self): + ex, _ = effective_fov_mm(self.calib, self.nav) + # 2048 * 1.05456 / 1000 = ~2.16 mm, minus 0.2 overlap + self.assertAlmostEqual(ex, 2048 * 1.05456 / 1000.0 - 0.2, places=4) + + def test_fovs_per_for(self): + nx, ny = fields_of_view_per_field_of_reference(self.calib, self.nav) + self.assertEqual((nx, ny), (4, 4)) # floor(2*4.5/1.96) == 4 + + def test_offsets_count_and_centered(self): + offsets = galvo_field_of_view_offsets_mm(self.calib, self.nav) + self.assertEqual(len(offsets), 16) # 4x4 + # symmetric about origin -> mean ~ 0 + mx = sum(o[0] for o in offsets) / len(offsets) + my = sum(o[1] for o in offsets) / len(offsets) + self.assertAlmostEqual(mx, 0.0, places=9) + self.assertAlmostEqual(my, 0.0, places=9) + + def test_serpentine_rows_reverse(self): + offsets = galvo_field_of_view_offsets_mm(self.calib, self.nav) + # first row left->right (increasing x), second row right->left (decreasing x) + row0 = offsets[0:4] + row1 = offsets[4:8] + self.assertTrue(row0[0][0] < row0[-1][0]) + self.assertTrue(row1[0][0] > row1[-1][0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/revvity/celigo/tests/reliability_tests.py b/pylabrobot/revvity/celigo/tests/reliability_tests.py new file mode 100644 index 00000000000..e07392441fb --- /dev/null +++ b/pylabrobot/revvity/celigo/tests/reliability_tests.py @@ -0,0 +1,890 @@ +"""Reliability tests for the consolidated Celigo controller implementation.""" + +import asyncio +import struct +import unittest +from contextlib import ExitStack +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Tuple +from unittest.mock import AsyncMock, patch + +from pylabrobot.io.ftdi import FTDI +from pylabrobot.revvity.celigo.celigo import ( + _MAX_RESPONSE_PAYLOAD_BYTES, + _STATUS_INTERLOCK_OPEN, + CeligoError, + ControllerInfo, + ControllerStatus, + _fletcher16, +) +from pylabrobot.revvity.celigo.config import Calibrated2DPolynomialTransform, CeligoHardwareConfig +from pylabrobot.revvity.celigo.galvo import dac_count_to_volts +from pylabrobot.revvity.celigo.motion import _decode_oem_response +from pylabrobot.revvity.celigo.tests.helpers import ( + make_calibration_config, + make_celigo, + make_galvo_config, + make_linear_axis_config, +) + + +def _oem_response(content: bytes) -> bytes: + body = b"\x02" + content + b"\x03" + checksum = 0 + for value in body: + checksum ^= value + return body + bytes([checksum]) + + +def _motor_response(reply: bytes, status: int = 0) -> bytes: + return struct.pack(">HH", status, len(reply)) + reply + + +class TestOemResponse(unittest.TestCase): + def test_valid_response_is_unwrapped(self): + self.assertEqual(_decode_oem_response(_oem_response(b"0`123")), "/0`123") + + def test_checksum_failure_is_rejected(self): + response = bytearray(_oem_response(b"0`123")) + response[-1] ^= 0x01 + with self.assertRaisesRegex(CeligoError, "checksum failure"): + _decode_oem_response(bytes(response)) + + def test_missing_frame_fields_are_rejected(self): + for response in (b"0`123", b"\x020`123", b"\x020`123\x03"): + with self.subTest(response=response), self.assertRaises(CeligoError): + _decode_oem_response(response) + + +class TestMotorReliability(unittest.IsolatedAsyncioTestCase): + async def test_public_z_move_converts_millimeters_to_internal_ticks(self): + driver = make_celigo( + hardware=CeligoHardwareConfig( + z_axis=make_linear_axis_config( + axis_index=3, + min_position=0, + max_position=10, + mm_per_encoder_tick=0.5, + ) + ) + ) + calls = [] + + async def move_ticks( + target_encoder_ticks, + arrival_tolerance_ticks=None, + **_kwargs, + ): + calls.append((target_encoder_ticks, arrival_tolerance_ticks)) + return target_encoder_ticks + + with patch.object(driver.z_axis, "move_to_ticks", move_ticks): + settled_mm = await driver.z_axis.move_to(2.5) + self.assertEqual(calls, [(5, None)]) + self.assertEqual(settled_mm, 2.5) + + async def test_public_position_read_returns_millimeters(self): + driver = make_celigo( + hardware=CeligoHardwareConfig( + z_axis=make_linear_axis_config( + axis_index=3, + min_position=0, + max_position=10, + home_offset=1, + mm_per_encoder_tick=0.5, + ) + ) + ) + + async def request_encoder_ticks(): + return 7 + + with patch.object(driver.z_axis, "request_encoder_ticks", request_encoder_ticks): + self.assertEqual(await driver.z_axis.request_position(), 2.5) + + async def test_xyz_move_requires_explicit_trust_after_vendor_homing(self): + driver = make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config( + axis_index=1, + min_position=0, + max_position=10, + mm_per_encoder_tick=1, + ) + ) + ) + + async def send(*_args, **_kwargs): + self.fail("untrusted move reached motor IO") + + with ( + patch.object(driver.motor_controller, "send_command", send), + self.assertRaisesRegex(CeligoError, "no position reference"), + ): + await driver.x_axis.move_to(5) + + async def test_assume_homed_adopts_an_in_range_external_position(self): + driver = make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config( + axis_index=1, + min_position=0, + max_position=10, + mm_per_encoder_tick=1, + ) + ) + ) + restored_modes = [] + + async def request_encoder_ticks(): + return 4 + + async def set_mode(mode): + restored_modes.append(mode) + + with ( + patch.object(driver.x_axis, "request_encoder_ticks", request_encoder_ticks), + patch.object(driver.x_axis.motor, "_set_mode", set_mode), + ): + self.assertEqual(await driver.x_axis.assume_homed(), 4) + self.assertTrue(driver.x_axis.has_position_reference) + self.assertEqual(restored_modes, [0]) + + async def test_invalid_axis_scale_fails_closed_before_io(self): + driver = make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config( + axis_index=1, + min_position=0, + max_position=10, + mm_per_encoder_tick=0, + ) + ) + ) + + async def send(*_args, **_kwargs): + self.fail("invalid-scale move reached motor IO") + + with ( + patch.object(driver.motor_controller, "send_command", send), + self.assertRaisesRegex(CeligoError, "invalid mm_per_encoder_tick"), + ): + await driver.x_axis.move_to(5) + + async def test_public_move_rejects_sub_tick_out_of_range_mm(self): + driver = make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config( + axis_index=1, + min_position=0, + max_position=10, + mm_per_encoder_tick=1, + ) + ) + ) + + async def move_ticks(*_args, **_kwargs): + self.fail("out-of-range millimeter move reached tick motion") + + with ( + patch.object(driver.x_axis, "move_to_ticks", move_ticks), + self.assertRaisesRegex(CeligoError, "outside configured range"), + ): + await driver.x_axis.move_to(-0.49) + + async def test_requested_move_tolerance_reaches_arrival_check(self): + axis = make_linear_axis_config( + axis_index=1, + min_position=0, + max_position=10, + mm_per_encoder_tick=0.5, + fine_position_error_window=4, + ) + driver = make_celigo(hardware=CeligoHardwareConfig(x_axis=axis)) + calls = [] + + async def configured_move(target, **kwargs): + calls.append((target, kwargs)) + return target + + with patch.object(driver.x_axis, "move_to_ticks", configured_move): + self.assertEqual(await driver.x_axis.move_to(2.5, tolerance_mm=0.5), 2.5) + self.assertEqual(calls, [(5, {"arrival_tolerance_ticks": 1})]) + + async def test_absolute_move_rejects_configured_out_of_range_target_before_io(self): + driver = make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config( + axis_index=1, + min_position=1, + max_position=3, + mm_per_encoder_tick=0.5, + ) + ) + ) + + async def send(*_args, **_kwargs): + self.fail("out-of-range move reached motor IO") + + with ( + patch.object(driver.motor_controller, "send_command", send), + self.assertRaisesRegex(CeligoError, "outside configured range"), + ): + await driver.x_axis.move_to(7) + + async def test_bad_oem_checksum_is_retried(self): + driver = make_celigo() + driver.controller_info = ControllerInfo(0, (1, 3, 0), 512) + bad = bytearray(_oem_response(b"0`42")) + bad[-1] ^= 0x01 + responses = [ + _motor_response(bytes(bad)), + _motor_response(_oem_response(b"0`42")), + ] + calls = 0 + + async def transact(_opcode, _payload): + nonlocal calls + calls += 1 + return responses.pop(0) + + with patch.object(driver, "send_command", transact): + self.assertEqual(await driver.motor_controller.send_command("/1?8\r"), "/0`42") + self.assertEqual(calls, 2) + + async def test_wlen_motor_comm_error_is_retried(self): + driver = make_celigo() + driver.controller_info = ControllerInfo(0, (1, 3, 0), 512) + responses = [ + struct.pack(">H", 5025), # controller motor-communication error + _motor_response(_oem_response(b"0`7")), + ] + + async def transact(_opcode, _payload): + return responses.pop(0) + + with patch.object(driver, "send_command", transact): + self.assertEqual(await driver.motor_controller.send_command("/1?8\r"), "/0`7") + + async def test_truncated_motor_response_is_rejected(self): + driver = make_celigo() + driver.controller_info = ControllerInfo(0, (1, 2, 999), 512) + + async def transact(_opcode, _payload): + return b"\x00" + + with ( + patch.object(driver, "send_command", transact), + self.assertRaisesRegex(CeligoError, "Truncated motor query"), + ): + await driver.motor_controller.send_command("/1?8\r") + + async def test_oversize_motor_command_is_rejected_before_io(self): + driver = make_celigo() + driver.controller_info = ControllerInfo(0, (1, 2, 999), 512) + + async def transact(_opcode, _payload): + self.fail("oversize command reached the transport") + + with ( + patch.object(driver, "send_command", transact), + self.assertRaisesRegex(ValueError, "maximum is 512"), + ): + await driver.motor_controller.send_command("x" * 513) + + async def test_motor_command_framing_is_derived_from_controller_firmware(self): + driver = make_celigo() + with self.assertRaisesRegex(CeligoError, "before controller identification"): + await driver.motor_controller.send_command("/1?8\r") + + opcodes = [] + + async def transact(opcode, _payload): + opcodes.append(opcode) + reply = b"/0`" if opcode == 44 else _oem_response(b"0`") + return _motor_response(reply) + + with patch.object(driver, "send_command", transact): + driver.controller_info = ControllerInfo(0, (1, 2, 999), 512) + await driver.motor_controller.send_command("/1?8\r") + + driver.controller_info = ControllerInfo(0, (1, 3, 0), 512) + await driver.motor_controller.send_command("/1?8\r") + self.assertEqual(opcodes, [44, 47]) + + +class TestResponseValidation(unittest.IsolatedAsyncioTestCase): + async def test_controller_status_is_decoded_into_named_fields(self): + driver = make_celigo() + + async def transact(_opcode): + return struct.pack(">II", 0b1101, 42) + + with patch.object(driver, "send_command", transact): + status = await driver.request_controller_status() + self.assertEqual(status.raw_flags, 0b1101) + self.assertEqual(status.extended_status, 42) + self.assertTrue(status.busy) + self.assertFalse(status.error) + self.assertTrue(status.interlock_open) + self.assertTrue(status.controller_failed) + self.assertTrue(status.has_controller_fault) + self.assertTrue(status.has_laser_safety_fault) + + async def test_analog_output_reply_must_echo_the_requested_channel(self): + driver = make_celigo() + + async def send_command(_opcode, _payload): + return struct.pack(">HH", 1, 123) + + with ( + patch.object(driver, "send_command", send_command), + self.assertRaisesRegex(CeligoError, "requested 2, received 1"), + ): + await driver.request_analog_output_count(2) + + async def test_corrupt_header_checksum_never_retransmits_command(self): + # Independent reference implementation: no production checksum helper is used. + def reference_fletcher(data): + first = second = 0xFF + for offset in range(0, len(data), 21): + for value in data[offset : offset + 21]: + first += value + second += first + first = (first & 0xFF) + (first >> 8) + second = (second & 0xFF) + (second >> 8) + first = (first & 0xFF) + (first >> 8) + second = (second & 0xFF) + (second >> 8) + return bytes((first & 0xFF, second & 0xFF)) + + class CorruptReplyIO: + def __init__(self): + header = bytearray(12) + header[1] = 23 + struct.pack_into(">i", header, 2, 1) + struct.pack_into(">i", header, 6, 0) + header[10:12] = reference_fletcher(header[:10]) + header[10] ^= 1 + self.reply = bytes(header) + self.writes = 0 + + async def write(self, data): + self.writes += 1 + return len(data) + + async def read(self, count): + result, self.reply = self.reply[:count], self.reply[count:] + return result + + async def usb_purge_rx_buffer(self): + return None + + async def usb_purge_tx_buffer(self): + return None + + driver = make_celigo() + driver._command_sequence = 0 + driver.reply_timeout = 0.1 + io = CorruptReplyIO() + with ( + patch.object(driver, "io", io), + self.assertRaisesRegex(CeligoError, "checksum failure"), + ): + await driver.send_command(23) + self.assertEqual(io.writes, 1) + + async def test_oversize_payload_is_rejected_before_body_read(self): + driver = make_celigo() + header = bytearray(12) + header[1] = 23 + struct.pack_into(">i", header, 2, 7) + struct.pack_into(">i", header, 6, _MAX_RESPONSE_PAYLOAD_BYTES + 1) + header[10], header[11] = _fletcher16(header, 10) + reads = 0 + + async def read_exact(_count, _reply_timeout): + nonlocal reads + reads += 1 + return bytes(header) + + with ( + patch.object(driver, "_read_exact_bytes", read_exact), + self.assertRaisesRegex(CeligoError, "maximum"), + ): + await driver._read_controller_response(23, 7, 0.1) + self.assertEqual(reads, 1) + + async def test_galvo_busy_bytes_match_vendor_semantics(self): + driver = make_celigo() + driver.config.hardware = CeligoHardwareConfig( + x_galvo=make_galvo_config(enabled=True), + y_galvo=make_galvo_config(enabled=True), + ) + + async def transact(_opcode, _payload=b""): + return struct.pack(">BBHHiiiBhh", 0, 1, 32768, 32768, 0, 0, 0, 0, 0, 0) + + with patch.object(driver, "send_command", transact): + status = await driver.galvo.request_controller_status() + self.assertTrue(status.x_busy) + self.assertFalse(status.y_busy) + self.assertAlmostEqual(status.x_hardware_voltage, dac_count_to_volts(32768)) + self.assertAlmostEqual(status.y_hardware_voltage, dac_count_to_volts(32768)) + + async def test_short_write_purges_both_buffers(self): + class ShortWriteIO: + def __init__(self): + self.rx_purges = 0 + self.tx_purges = 0 + + async def write(self, data): + return len(data) - 1 + + async def usb_purge_rx_buffer(self): + self.rx_purges += 1 + + async def usb_purge_tx_buffer(self): + self.tx_purges += 1 + + driver = make_celigo() + driver._command_sequence = 1 + io = ShortWriteIO() + with patch.object(driver, "io", io), self.assertRaisesRegex(CeligoError, "Short write"): + await driver.send_command(23) + self.assertEqual((io.rx_purges, io.tx_purges), (1, 1)) + + async def test_cancelled_read_purges_both_buffers_before_returning(self): + read_started = asyncio.Event() + + class BlockingReadIO: + def __init__(self): + self.purges = [] + + async def write(self, data): + return len(data) + + async def read(self, _count): + read_started.set() + await asyncio.Future() + + async def usb_purge_rx_buffer(self): + self.purges.append("rx") + + async def usb_purge_tx_buffer(self): + self.purges.append("tx") + + driver = make_celigo() + io = BlockingReadIO() + with patch.object(driver, "io", io): + command = asyncio.create_task(driver.send_command(23)) + await read_started.wait() + command.cancel() + with self.assertRaises(asyncio.CancelledError): + await command + + self.assertEqual(io.purges, ["rx", "tx"]) + + +class TestSelfTest(unittest.IsolatedAsyncioTestCase): + @staticmethod + def _configured_driver(): + return make_celigo( + hardware=CeligoHardwareConfig( + x_axis=make_linear_axis_config( + axis_index=1, + min_position=0, + max_position=20, + mm_per_encoder_tick=1, + encoder_to_motor_tick_ratio=1, + ), + y_axis=make_linear_axis_config( + axis_index=2, + min_position=0, + max_position=20, + mm_per_encoder_tick=1, + encoder_to_motor_tick_ratio=2, + ), + z_axis=make_linear_axis_config( + axis_index=3, + min_position=0, + max_position=20, + mm_per_encoder_tick=1, + encoder_to_motor_tick_ratio=3, + ), + ) + ) + + async def test_encoder_ratio_failure_is_attributed_to_the_correct_motor(self): + driver = self._configured_driver() + with ExitStack() as patches: + patches.enter_context( + patch.multiple( + driver, + request_controller_status=AsyncMock(return_value=ControllerStatus(0, 0)), + request_controller_info=AsyncMock(return_value=ControllerInfo(1, (1, 3, 0), 256)), + request_detected_motor_addresses=AsyncMock(return_value=[]), + request_digital_input_bitmask=AsyncMock(return_value=0), + ) + ) + for axis in driver._configured_motion_axes(): + patches.enter_context( + patch.multiple( + axis, + request_encoder_ticks=AsyncMock(return_value=10), + request_encoder_ratio=AsyncMock( + return_value={1: 1.0, 2: 999.0, 3: 3.0}[axis.axis_index] + ), + ) + ) + report = await driver.run_self_test() + + self.assertFalse(report.passed) + self.assertEqual(report.failures, ("motor_2_encoder_ratio",)) + self.assertFalse(report.checks["motor_2_encoder_ratio"]["matches"]) + + async def test_unpopulated_generic_interlock_does_not_fail_controller_self_test(self): + driver = self._configured_driver() + with ExitStack() as patches: + patches.enter_context( + patch.multiple( + driver, + request_controller_status=AsyncMock( + return_value=ControllerStatus(_STATUS_INTERLOCK_OPEN, 0) + ), + request_controller_info=AsyncMock(return_value=ControllerInfo(1, (1, 3, 0), 256)), + request_detected_motor_addresses=AsyncMock(return_value=[]), + request_digital_input_bitmask=AsyncMock(return_value=0), + ) + ) + for axis in driver._configured_motion_axes(): + patches.enter_context( + patch.multiple( + axis, + request_encoder_ticks=AsyncMock(return_value=10), + request_encoder_ratio=AsyncMock(return_value=float(axis.axis_index)), + ) + ) + report = await driver.run_self_test() + + self.assertTrue(report.passed) + self.assertEqual(report.failures, ()) + self.assertTrue(report.checks["controller_status"].interlock_open) + + async def test_controller_fault_is_attributed_to_existing_status_check(self): + driver = self._configured_driver() + with ExitStack() as patches: + patches.enter_context( + patch.multiple( + driver, + request_controller_status=AsyncMock(return_value=ControllerStatus(2, 0)), + request_controller_info=AsyncMock(return_value=ControllerInfo(1, (1, 3, 0), 256)), + request_detected_motor_addresses=AsyncMock(return_value=[]), + request_digital_input_bitmask=AsyncMock(return_value=0), + ) + ) + for axis in driver._configured_motion_axes(): + patches.enter_context( + patch.multiple( + axis, + request_encoder_ticks=AsyncMock(return_value=10), + request_encoder_ratio=AsyncMock(return_value=float(axis.axis_index)), + ) + ) + report = await driver.run_self_test() + + self.assertEqual(report.failures, ("controller_status",)) + self.assertIn("controller_status", report.checks) + + async def test_failed_galvo_calibration_has_a_named_check(self): + driver = self._configured_driver() + driver.config.galvo_calibrations = { + 3: Calibrated2DPolynomialTransform( + forward={}, + reverse={}, + order=2, + successful=False, + ), + } + with ExitStack() as patches: + patches.enter_context( + patch.multiple( + driver, + request_controller_status=AsyncMock(return_value=ControllerStatus(0, 0)), + request_controller_info=AsyncMock(return_value=ControllerInfo(1, (1, 3, 0), 256)), + request_detected_motor_addresses=AsyncMock(return_value=[]), + request_digital_input_bitmask=AsyncMock(return_value=0), + ) + ) + for axis in driver._configured_motion_axes(): + patches.enter_context( + patch.multiple( + axis, + request_encoder_ticks=AsyncMock(return_value=10), + request_encoder_ratio=AsyncMock(return_value=float(axis.axis_index)), + ) + ) + report = await driver.run_self_test() + + self.assertEqual(report.failures, ("galvo_calibration_3",)) + self.assertFalse(report.checks["galvo_calibration_3"]) + + async def test_motion_checks_require_active_checks(self): + driver = self._configured_driver() + with self.assertRaisesRegex(ValueError, "requires run_active_checks"): + await driver.run_self_test(run_motion_checks=True) + + async def test_motion_checks_round_trip_each_linear_axis(self): + driver = self._configured_driver() + moves = [] + + async def no_op(): + return None + + with ExitStack() as patches: + patches.enter_context( + patch.multiple( + driver, + request_controller_status=AsyncMock(return_value=ControllerStatus(0, 0)), + request_controller_info=AsyncMock(return_value=ControllerInfo(1, (1, 3, 0), 256)), + request_detected_motor_addresses=AsyncMock(return_value=[]), + request_digital_input_bitmask=AsyncMock(return_value=0), + capture_frame=no_op, + ) + ) + patches.enter_context(patch.object(driver.galvo, "home", no_op)) + for axis in driver._configured_motion_axes(): + + async def move_to_ticks(target_encoder_ticks, axis_name=axis.name): + moves.append((axis_name, target_encoder_ticks)) + return target_encoder_ticks + + patches.enter_context( + patch.multiple( + axis, + request_encoder_ticks=AsyncMock(return_value=10), + request_encoder_ratio=AsyncMock(return_value=float(axis.axis_index)), + move_to_ticks=move_to_ticks, + ) + ) + report = await driver.run_self_test( + run_active_checks=True, + run_motion_checks=True, + ) + + self.assertTrue(report.passed) + self.assertEqual( + moves, + [ + ("x", 15), + ("x", 10), + ("y", 15), + ("y", 10), + ("z", 15), + ("z", 10), + ], + ) + + +class _LifecycleIO: + def __init__(self): + self.stopped = False + + async def setup(self): + return None + + async def set_baudrate(self, _baudrate): + return None + + async def set_line_property(self, *_args): + return None + + async def set_latency_timer(self, _latency): + return None + + async def usb_purge_rx_buffer(self): + return None + + async def usb_purge_tx_buffer(self): + return None + + async def stop(self): + self.stopped = True + + +class _LifecycleCamera: + def __init__(self): + self.is_open = False + self.width = 2464 + self.height = 2056 + self.exposure_ms = 1.0 + self.gain = 1.0 + self.setup_calls = 0 + self.stop_calls = 0 + self.format_calls = [] + + async def setup(self): + self.setup_calls += 1 + self.is_open = True + + async def stop(self): + self.stop_calls += 1 + self.is_open = False + + async def set_frame_format(self, width, height, x_offset=None, y_offset=None): + self.format_calls.append((width, height, x_offset, y_offset)) + self.width = width + self.height = height + return width, height + + +@dataclass(frozen=True) +class _UsbDevice: + bus: int + address: int + port_numbers: Tuple[int, ...] + + +class TestLifecycleReliability(unittest.IsolatedAsyncioTestCase): + async def test_setup_owns_camera_and_applies_calibrated_geometry(self): + celigo = make_celigo() + io = _LifecycleIO() + camera = _LifecycleCamera() + celigo.config.calibration = make_calibration_config( + image_width_pixels=2048, + image_height_pixels=2048, + ) + celigo.baudrate = 230400 + celigo.latency_ms = 2 + celigo.controller_info = None + celigo.config.hardware = CeligoHardwareConfig() + celigo._connected = False + initialization_calls = 0 + homing_calls = 0 + + async def no_op(*_args, **_kwargs): + return None + + async def status(): + return ControllerStatus(0, 0) + + async def identity(): + return ControllerInfo(1, (1, 3, 0), 256) + + async def track_hardware_initialization(): + nonlocal initialization_calls + initialization_calls += 1 + + async def track_homing(): + nonlocal homing_calls + homing_calls += 1 + + with patch.multiple( + celigo, + io=io, + camera=camera, + abort_controller_operation=no_op, + request_controller_status=status, + request_controller_info=identity, + _initialize_hardware=track_hardware_initialization, + _initialize_safe_outputs=no_op, + home_imaging_axes=track_homing, + ): + await celigo.setup() + self.assertEqual(initialization_calls, 1) + self.assertEqual(homing_calls, 1) + self.assertEqual(camera.setup_calls, 1) + self.assertEqual(camera.format_calls, [(2048, 2048, None, None)]) + self.assertEqual((camera.width, camera.height), (2048, 2048)) + self.assertTrue(camera.is_open) + self.assertTrue(celigo._connected) + await celigo.stop() + self.assertEqual(camera.stop_calls, 1) + self.assertFalse(camera.is_open) + + async def test_normal_stop_aborts_and_clears_outputs_before_transport_close(self): + celigo = make_celigo() + io = _LifecycleIO() + celigo._connected = True + operations = [] + + async def abort_controller_operation(): + operations.append("abort_controller_operation") + + async def safe_outputs(): + operations.append("safe_outputs") + + with patch.multiple( + celigo, + io=io, + abort_controller_operation=abort_controller_operation, + _initialize_safe_outputs=safe_outputs, + ): + await celigo.stop() + self.assertEqual(operations, ["abort_controller_operation", "safe_outputs"]) + self.assertTrue(io.stopped) + + async def test_setup_closes_transport_when_identity_fails(self): + celigo = make_celigo() + io = _LifecycleIO() + celigo.baudrate = 230400 + celigo.latency_ms = 2 + celigo.controller_info = None + + async def status(): + return ControllerStatus(0, 0) + + async def identity(): + raise CeligoError("simulated identity failure") + + async def abort_controller_operation(): + return None + + with ( + patch.multiple( + celigo, + io=io, + request_controller_status=status, + request_controller_info=identity, + abort_controller_operation=abort_controller_operation, + ), + self.assertRaisesRegex(CeligoError, "identity failure"), + ): + await celigo.setup() + self.assertTrue(io.stopped) + + async def test_stop_closes_transport_when_camera_stop_fails(self): + class FailingCamera: + async def stop(self): + raise RuntimeError("simulated camera failure") + + celigo = make_celigo() + io = _LifecycleIO() + with ( + patch.multiple(celigo, io=io, camera=FailingCamera()), + self.assertRaisesRegex(RuntimeError, "camera failure"), + ): + await celigo.stop() + self.assertTrue(io.stopped) + + +class TestFtdiTopologySelection(unittest.TestCase): + def test_topology_resolves_exact_bus_and_device_address(self): + device = _UsbDevice(bus=3, address=17, port_numbers=(2, 4)) + usb_module = SimpleNamespace(core=SimpleNamespace(find=lambda **_kwargs: [device])) + with ( + patch("pylabrobot.io.ftdi.HAS_PYLIBFTDI", True), + patch("pylabrobot.io.ftdi.HAS_PYUSB", True), + patch("pylabrobot.io.ftdi.usb", usb_module, create=True), + ): + ftdi = FTDI( + human_readable_device_name="Celigo", + vid=0x0403, + pid=0x6001, + usb_address="3-2.4", + ) + self.assertEqual(ftdi._resolve_device_location(), (3, 17)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/revvity/celigo/tests/scan_tests.py b/pylabrobot/revvity/celigo/tests/scan_tests.py new file mode 100644 index 00000000000..c3ed46b573a --- /dev/null +++ b/pylabrobot/revvity/celigo/tests/scan_tests.py @@ -0,0 +1,561 @@ +"""Tests for Celigo scan specifications, planning, and execution.""" + +import inspect +import itertools +import math +import unittest +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from pylabrobot.resources.corning.plates import cor_96_wellplate_360uL_Fb +from pylabrobot.revvity.celigo.camera import CameraFrame +from pylabrobot.revvity.celigo.config import IlluminationChannelConfig +from pylabrobot.revvity.celigo.navigation import well_to_sample_mm +from pylabrobot.revvity.celigo.scan import ( + Capture, + FrameResult, + ScanEstimateModel, + ScanRegion, + ScanResult, + ScanSpec, + build_scan_plan, +) +from pylabrobot.revvity.celigo.tests.helpers import ( + make_calibration_config, + make_celigo, + make_navigation_config, + make_test_config, +) + + +def _channel( + name: str, + *, + x_correction: float = 1.0, + y_correction: float = 1.0, + z_offset_mm: float = 0.0, +) -> IlluminationChannelConfig: + return IlluminationChannelConfig( + name=name, + display_name=name.title(), + logical_filter=1, + bit_value=None, + intensity_percent=0, + lighting_io_name=name, + strobe=False, + z_offset_to_brightfield_mm=z_offset_mm, + mm_per_pixel_x_correction_to_brightfield=x_correction, + mm_per_pixel_y_correction_to_brightfield=y_correction, + ) + + +def _config(): + config = make_test_config() + config.calibration = make_calibration_config( + microns_per_pixel_x=1.0, + microns_per_pixel_y=1.0, + image_width_pixels=1000, + image_height_pixels=1000, + ) + config.navigation = make_navigation_config( + frame_overlap_x_mm=0.1, + frame_overlap_y_mm=0.1, + max_galvo_deflection_x_mm=1.7, + max_galvo_deflection_y_mm=1.7, + ) + config.channels_by_magnification[config.magnification] = { + "brightfield": _channel("brightfield"), + "green": _channel("green", z_offset_mm=0.2), + } + return config + + +def _frame() -> CameraFrame: + return CameraFrame( + data=b"\x00", + width=1, + height=1, + bit_depth=8, + exposure_ms=1.0, + gain=1.0, + captured_at=0.0, + ) + + +def _overlap_area(first: ScanRegion, second: ScanRegion) -> float: + width = max(0.0, min(first.right, second.right) - max(first.left, second.left)) + height = max(0.0, min(first.bottom, second.bottom) - max(first.top, second.top)) + return width * height + + +class TestScanRegion(unittest.TestCase): + def test_explicit_bounds_and_area(self): + region = ScanRegion.from_bounds_mm(left=5, top=5, right=122, bottom=81) + + self.assertEqual(region.width_mm, 117) + self.assertEqual(region.height_mm, 76) + self.assertEqual(region.area_mm2, 8892) + + def test_invalid_bounds_are_rejected(self): + with self.assertRaisesRegex(ValueError, "right"): + ScanRegion.from_bounds_mm(left=5, top=5, right=5, bottom=81) + with self.assertRaisesRegex(ValueError, "finite"): + ScanRegion.from_bounds_mm(left=5, top=float("nan"), right=122, bottom=81) + + +class TestScanSpec(unittest.TestCase): + def setUp(self): + self.plate = cor_96_wellplate_360uL_Fb(name="imaging_plate") + + def test_single_capture_shorthand_is_normalized(self): + spec = ScanSpec.points( + [(10, 20)], + channel="brightfield", + exposure_ms=1.25, + gain=2, + autofocus="image", + ) + + self.assertEqual( + spec.captures, + (Capture(channel="brightfield", exposure_ms=1.25, gain=2),), + ) + self.assertEqual(spec.autofocus, "image") + + def test_hardware_autofocus_is_rejected_during_specification(self): + with self.assertRaisesRegex(ValueError, "None or 'image'"): + ScanSpec.points( + [(10, 20)], + channel="brightfield", + autofocus="hardware", # type: ignore[arg-type] + ) + + def test_capture_list_is_mutually_exclusive_with_shorthand(self): + capture = Capture(channel="brightfield") + with self.assertRaisesRegex(ValueError, "cannot be combined"): + ScanSpec.points( + [(10, 20)], + channel="brightfield", + captures=[capture], + ) + with self.assertRaisesRegex(ValueError, "provide channel or captures"): + ScanSpec.points([(10, 20)]) + + def test_capture_values_are_validated_early(self): + with self.assertRaisesRegex(ValueError, "exposure_ms"): + Capture(channel="brightfield", exposure_ms=0) + with self.assertRaisesRegex(ValueError, "gain"): + Capture(channel="brightfield", gain=-1) + with self.assertRaisesRegex(ValueError, "channel"): + Capture(channel=" ") + + def test_points_are_anonymous_and_need_no_bounds(self): + self.assertNotIn("labels", inspect.signature(ScanSpec.points).parameters) + spec = ScanSpec.points( + [(250.0, -10.0)], + block_shape=(2, 3), + channel="brightfield", + ) + plan = build_scan_plan(_config(), spec) + + self.assertIsNone(plan.blocks[0].label) + self.assertAlmostEqual(plan.blocks[0].center_x_mm, 250.0) + self.assertAlmostEqual(plan.blocks[0].center_y_mm, -10.0) + + def test_wells_are_normalized_and_converted_once(self): + spec = ScanSpec.wells( + self.plate, + ["a1", " B2 "], + block_shape=(2, 3), + channel="brightfield", + ) + plan = build_scan_plan(_config(), spec) + + self.assertFalse(hasattr(spec.geometry, "plate")) + self.assertEqual([block.label for block in plan.blocks], ["A1", "B2"]) + expected_x_mm, expected_y_mm = well_to_sample_mm(self.plate, "A1") + self.assertAlmostEqual(plan.blocks[0].center_x_mm, expected_x_mm) + self.assertAlmostEqual(plan.blocks[0].center_y_mm, expected_y_mm) + + def test_block_shape_may_be_smaller_than_the_calibrated_maximum(self): + for block_shape in ((1, 1), (2, 3), (4, 4)): + with self.subTest(block_shape=block_shape): + plan = build_scan_plan( + _config(), + ScanSpec.points( + [(10, 10)], + block_shape=block_shape, + channel="brightfield", + ), + ) + self.assertEqual(plan.blocks[0].block_shape, block_shape) + self.assertEqual(plan.frame_count, block_shape[0] * block_shape[1]) + + def test_block_shape_cannot_exceed_galvo_reach(self): + spec = ScanSpec.points( + [(10, 10)], + block_shape=(5, 4), + channel="brightfield", + ) + with self.assertRaisesRegex(ValueError, "calibrated galvo limit is 4x4"): + build_scan_plan(_config(), spec) + + +class TestScanPlanning(unittest.TestCase): + def setUp(self): + self.config = _config() + self.bounds = ScanRegion.from_bounds_mm(left=0, top=0, right=20, bottom=20) + + def test_random_blocks_are_seeded_distinct_and_non_overlapping(self): + spec = ScanSpec.random( + self.bounds, + count=10, + block_shape=(4, 4), + seed=42, + channel="brightfield", + ) + first = build_scan_plan(self.config, spec) + second = build_scan_plan(self.config, spec) + other = build_scan_plan( + self.config, + ScanSpec.random( + self.bounds, + count=10, + block_shape=(4, 4), + seed=43, + channel="brightfield", + ), + ) + + centers = [(block.center_x_mm, block.center_y_mm) for block in first.blocks] + self.assertEqual( + centers, + [(block.center_x_mm, block.center_y_mm) for block in second.blocks], + ) + self.assertNotEqual( + centers, + [(block.center_x_mm, block.center_y_mm) for block in other.blocks], + ) + for index, block in enumerate(first.blocks): + for other_block in first.blocks[index + 1 :]: + self.assertAlmostEqual(_overlap_area(block.bounds, other_block.bounds), 0.0) + + def test_random_blocks_use_the_minimum_travel_order(self): + plan = build_scan_plan( + self.config, + ScanSpec.random( + self.bounds, + count=5, + block_shape=(4, 4), + seed=42, + channel="brightfield", + ), + ) + centers = [(block.center_x_mm, block.center_y_mm) for block in plan.blocks] + + route_length = sum(math.dist(start, end) for start, end in zip(centers, centers[1:])) + minimum_length = min( + sum(math.dist(start, end) for start, end in zip(order, order[1:])) + for order in itertools.permutations(centers) + ) + + self.assertAlmostEqual(route_length, minimum_length) + + def test_too_many_non_overlapping_blocks_is_rejected(self): + spec = ScanSpec.random( + self.bounds, + count=26, + block_shape=(4, 4), + channel="brightfield", + ) + with self.assertRaisesRegex(ValueError, "only 25"): + build_scan_plan(self.config, spec) + + def test_full_coverage_uses_as_many_blocks_as_needed(self): + bounds = ScanRegion.from_bounds_mm(left=0, top=0, right=10, bottom=10) + plan = build_scan_plan( + self.config, + ScanSpec.full_coverage(bounds, channel="brightfield"), + ) + + self.assertEqual(plan.frame_count, 169) + self.assertEqual(plan.stage_position_count, 16) + self.assertAlmostEqual(plan.sampled_area_mm2, bounds.area_mm2) + self.assertAlmostEqual(plan.frames[0].position.sample_x_mm, 0.5) + self.assertAlmostEqual(plan.blocks[-1].bounds.bottom, 10.0) + + def test_multiple_captures_share_each_stage_block(self): + spec = ScanSpec.points( + [(5, 5), (15, 15)], + block_shape=(2, 3), + captures=[ + Capture(channel="brightfield", exposure_ms=10, gain=1), + Capture(channel="green", exposure_ms=20, gain=2), + ], + autofocus="image", + ) + plan = build_scan_plan(self.config, spec) + + self.assertEqual(plan.stage_position_count, 2) + self.assertEqual(plan.frame_count, 24) + self.assertEqual( + [frame.capture.channel for frame in plan.frames], + ["brightfield"] * 6 + ["green"] * 6 + ["brightfield"] * 6 + ["green"] * 6, + ) + self.assertTrue(all(frame.block is plan.blocks[0] for frame in plan.frames[:12])) + self.assertTrue(all(frame.block is plan.blocks[1] for frame in plan.frames[12:])) + + def test_estimates_include_capture_exposure_and_autofocus(self): + spec = ScanSpec.points( + [(5, 5), (15, 15)], + block_shape=(2, 3), + captures=[ + Capture(channel="brightfield", exposure_ms=10), + Capture(channel="green", exposure_ms=20), + ], + autofocus="image", + ) + plan = build_scan_plan( + self.config, + spec, + estimate_model=ScanEstimateModel( + seconds_per_frame=1, + seconds_per_stage_position=2, + seconds_per_autofocus=3, + bytes_per_pixel=2, + ), + ) + + self.assertEqual(plan.frame_count, 24) + self.assertEqual(plan.autofocus_count, 2) + self.assertEqual(plan.estimated_duration, timedelta(seconds=34.36)) + self.assertEqual(plan.estimated_storage_bytes, 48_000_000) + summary = str(plan) + self.assertIn("geometry: points(count=2, block_shape=2x3)", summary) + self.assertIn("channels: brightfield, green", summary) + self.assertIn("frames: 24", summary) + self.assertIn("estimated storage: 48 MB", summary) + + def test_unknown_channel_is_rejected_during_planning(self): + spec = ScanSpec.points([(5, 5)], channel="ultraviolet") + with self.assertRaisesRegex(ValueError, "Unknown channel 'ultraviolet'"): + build_scan_plan(self.config, spec) + + +class TestCeligoScanMethods(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.celigo = make_celigo(config=_config()) + self.plate = cor_96_wellplate_360uL_Fb(name="imaging_plate") + + async def test_execute_runs_the_exact_plan_and_moves_stage_once_per_block(self): + spec = ScanSpec.wells( + self.plate, + ["A1", "B2"], + block_shape=(2, 3), + captures=[ + Capture(channel="brightfield", exposure_ms=1.25, gain=1), + Capture(channel="green", exposure_ms=2.5, gain=2), + ], + autofocus="image", + ) + plan = self.celigo.plan(spec) + stage_moves = [] + acquisition_calls = [] + + async def move_to_scan_block(block): + stage_moves.append(block) + return block.stage_x_mm + 0.01, block.stage_y_mm + 0.02 + + async def acquire_scan_position( + position, + label, + channel, + exposure_ms, + gain, + autofocus, + z_mm, + settled_stage_position_mm, + ): + acquisition_calls.append( + { + "position": position, + "label": label, + "channel": channel, + "exposure_ms": exposure_ms, + "gain": gain, + "autofocus": autofocus, + "z_mm": z_mm, + "stage": settled_stage_position_mm, + } + ) + return SimpleNamespace( + frame=_frame(), + x_mm=settled_stage_position_mm[0], + y_mm=settled_stage_position_mm[1], + z_mm=1.0, + galvo_hardware_voltages=(0.1, 0.2), + focus=None, + ) + + with ( + patch.object(self.celigo, "_move_to_scan_block", move_to_scan_block), + patch.object(self.celigo, "_acquire_scan_position", acquire_scan_position), + ): + result = await self.celigo.execute(plan) + + self.assertIsInstance(result, ScanResult) + self.assertIs(result.plan, plan) + self.assertEqual(stage_moves, list(plan.blocks)) + self.assertEqual(len(acquisition_calls), 24) + self.assertEqual( + [call["label"] for call in acquisition_calls], + ["A1"] * 12 + ["B2"] * 12, + ) + self.assertEqual( + [call["channel"] for call in acquisition_calls], + [frame.capture.channel for frame in plan.frames], + ) + self.assertEqual( + [call["exposure_ms"] for call in acquisition_calls], + [frame.capture.exposure_ms for frame in plan.frames], + ) + self.assertEqual( + [call["autofocus"] for call in acquisition_calls], + ["image"] + [None] * 11 + ["image"] + [None] * 11, + ) + self.assertTrue(all(isinstance(frame_result, FrameResult) for frame_result in result.frames)) + self.assertTrue( + all( + frame_result.planned is planned for frame_result, planned in zip(result.frames, plan.frames) + ) + ) + + async def test_execute_rejects_a_plan_after_configuration_changes_before_motion(self): + plan = self.celigo.plan(ScanSpec.points([(5, 5)], channel="brightfield")) + self.celigo.config.magnification = 5 + + with ( + patch.object(self.celigo, "_move_to_scan_block", AsyncMock()) as move_to_scan_block, + self.assertRaisesRegex(ValueError, "configuration does not match"), + ): + await self.celigo.execute(plan) + + move_to_scan_block.assert_not_awaited() + + async def test_acquire_rejects_hardware_autofocus_before_motion(self): + with ( + patch.object(self.celigo, "move_to_well", AsyncMock()) as move_to_well, + self.assertRaisesRegex(ValueError, "None or 'image'"), + ): + await self.celigo.acquire( + "A1", + "brightfield", + autofocus="hardware", # type: ignore[arg-type] + ) + + move_to_well.assert_not_awaited() + + async def test_internal_execution_reports_frames_and_accepts_a_tuned_focus_seed(self): + plan = self.celigo.plan( + ScanSpec.points( + [(25, 20)], + captures=[Capture(channel="brightfield"), Capture(channel="green")], + ) + ) + z_targets = [] + reported = [] + + async def move_to_scan_block(block): + return block.stage_x_mm, block.stage_y_mm + + async def acquire_scan_position(**kwargs): + z_targets.append(kwargs["z_mm"]) + return SimpleNamespace( + frame=_frame(), + x_mm=1.0, + y_mm=2.0, + z_mm=kwargs["z_mm"], + galvo_hardware_voltages=(0.1, 0.2), + focus=None, + ) + + async def on_frame(frame): + reported.append(frame) + + with ( + patch.object(self.celigo, "_move_to_scan_block", move_to_scan_block), + patch.object(self.celigo, "_acquire_scan_position", acquire_scan_position), + ): + result = await self.celigo._execute_scan_plan( + plan, + on_frame=on_frame, + initial_brightfield_z_mm=2.5, + ) + + self.assertEqual(z_targets, [2.5, 2.7]) + self.assertEqual(reported, list(result.frames)) + + async def test_scan_is_plan_followed_by_execute(self): + spec = ScanSpec.points([(5, 5)], channel="brightfield") + compiled_plan = object() + expected_result = object() + planning_calls = [] + execution_calls = [] + + def plan(actual_spec, *, estimate_model=None): + planning_calls.append((actual_spec, estimate_model)) + return compiled_plan + + async def execute(actual_plan): + execution_calls.append(actual_plan) + return expected_result + + estimates = ScanEstimateModel(seconds_per_frame=1) + with ( + patch.object(self.celigo, "plan", plan), + patch.object(self.celigo, "execute", execute), + ): + result = await self.celigo.scan(spec, estimate_model=estimates) + + self.assertIs(result, expected_result) + self.assertEqual(planning_calls, [(spec, estimates)]) + self.assertEqual(execution_calls, [compiled_plan]) + + async def test_scan_wells_builds_the_single_capture_spec(self): + expected_result = object() + scan_calls = [] + + async def scan(spec, *, estimate_model=None): + scan_calls.append((spec, estimate_model)) + return expected_result + + estimates = ScanEstimateModel() + with patch.object(self.celigo, "scan", scan): + result = await self.celigo.scan_wells( + self.plate, + ["a1", "B2"], + channel="brightfield", + block_shape=(2, 3), + exposure_ms=1.25, + gain=2, + autofocus="image", + estimate_model=estimates, + ) + + self.assertIs(result, expected_result) + spec, actual_estimates = scan_calls[0] + self.assertIsInstance(spec, ScanSpec) + self.assertEqual( + spec.captures, + (Capture(channel="brightfield", exposure_ms=1.25, gain=2),), + ) + self.assertEqual(spec.autofocus, "image") + self.assertIs(actual_estimates, estimates) + planned = build_scan_plan(_config(), spec) + self.assertEqual([block.label for block in planned.blocks], ["A1", "B2"]) + self.assertEqual([block.block_shape for block in planned.blocks], [(2, 3), (2, 3)]) + + +if __name__ == "__main__": + unittest.main()