Hardware components and diagnostics#

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.

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.

Load configuration and inspect this build#

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.

from pathlib import Path

from pylabrobot.revvity import Celigo, CeligoConfig
from pylabrobot.revvity.celigo import CeligoError
config_root = Path("/path/to/Celigo/ConfigFiles")
lucam_sdk = Path("/path/to/liblucamapi.so")
usb_address = "3-2"

config = CeligoConfig.from_install(str(config_root))
celigo = Celigo(
  config=config,
  usb_address=usb_address,
  lucam_sdk=str(lucam_sdk),
)
mechanism_configs = {
  "x_axis": config.hardware.x_axis,
  "y_axis": config.hardware.y_axis,
  "z_axis": config.hardware.z_axis,
  "beam_expander": config.hardware.beam_expander,
  "camera_filter": config.hardware.camera_filter_wheel,
  "dichroic_filter": config.hardware.dichroic_filter_wheel,
  "excitation_filter": config.hardware.excitation_filter_wheel,
  "excitation_nd_filter": config.hardware.excitation_nd_filter_wheel,
  "laser_attenuator": config.hardware.laser_attenuator,
  "laser_nd_filter": config.hardware.laser_nd_filter_wheel,
  "magnification_changer": config.hardware.magnification_changer,
}
{
  name: {
    "motion_name": mechanism.motion_name,
    "axis_index": mechanism.axis_index,
    "enabled": mechanism.enabled,
  }
  for name, mechanism in mechanism_configs.items()
  if mechanism is not None
}

Connect and read controller identity#

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.

await celigo.setup()
controller_status = await celigo.request_controller_status()
motor_addresses = await celigo.request_detected_motor_addresses()
{
  "controller_info": celigo.controller_info,
  "firmware_version": celigo.controller_firmware_version,
  "status": controller_status,
  "busy": controller_status.busy,
  "controller_fault": controller_status.has_controller_fault,
  "laser_safety_fault": controller_status.has_laser_safety_fault,
  "motor_addresses": motor_addresses,
}

Run the read-only self-test#

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.

report = await celigo.run_self_test()
report.passed, report.failures, report.checks

Inspect axis objects and units#

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.

axis_snapshot = {}
for axis in (celigo.x_axis, celigo.y_axis, celigo.z_axis):
  encoder_ticks = await axis.request_encoder_ticks()
  axis_snapshot[axis.name] = {
    "axis_index": axis.axis_index,
    "initialized": axis.is_initialized,
    "position_reference": axis.has_position_reference,
    "encoder_ticks": encoder_ticks,
    "encoder_ratio": await axis.request_encoder_ratio(),
    "negative_limit": await axis.request_is_negative_limit_active(),
    "positive_limit": await axis.request_is_positive_limit_active(),
  }
axis_snapshot

Home and make bounded moves#

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.

await celigo.home_imaging_axes()
x_start_ticks = await celigo.x_axis.request_encoder_ticks()
x_start_mm = celigo.x_axis.encoder_ticks_to_mm(x_start_ticks)
x_target_mm = min(x_start_mm + 1.0, celigo.x_axis.config.max_position)
x_settled_mm = await celigo.x_axis.move_to(x_target_mm)
await celigo.x_axis.move_to(x_start_mm)
x_start_mm, x_settled_mm

Work with filter-wheel components#

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.

logical_filter_map = {
  entry.logical_number: entry.physical_number for entry in celigo.dichroic_filter.config.filter_map
}
brightfield_position = celigo.config.channels["brightfield"].logical_filter
settled_filter_ticks = await celigo.dichroic_filter.move_to(brightfield_position)
logical_filter_map, settled_filter_ticks

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.

magnification_config = config.hardware.magnification_changer
if (
  magnification_config is not None
  and magnification_config.enabled
  and magnification_config.axis_index > 0
):
  await celigo.magnification_changer.home()
  await celigo.magnification_changer.move_to(3)
celigo.config.magnification

Read galvo diagnostics#

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.

galvo_status = await celigo.galvo.request_controller_status()
x_calibration_errors = await celigo.galvo.request_calibration_errors("x")
y_calibration_errors = await celigo.galvo.request_calibration_errors("y")
{
  "status": galvo_status,
  "x_calibration_errors": x_calibration_errors,
  "y_calibration_errors": y_calibration_errors,
}

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.

brightfield_filter = celigo.config.channels["brightfield"].logical_filter
center_hardware_voltages = await celigo.galvo.home(
  logical_filter=brightfield_filter,
)
center_hardware_voltages

Inspect controller I/O#

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.

io_config = celigo.config.hardware.io
if io_config is None:
  raise RuntimeError("This instrument has no configured controller I/O")

io_inventory = {
  "digital": [
    (line.io_name, line.io_type, line.bit_index, line.invert) for line in io_config.digital_ios
  ],
  "lighting": [
    (output.io_name, output.channel, output.min_voltage, output.max_voltage)
    for output in io_config.lighting_ios
  ],
  "analog_inputs": [
    (input_config.io_name, input_config.channel) for input_config in io_config.analog_ins
  ],
}
io_inventory
lighting_readback = {}
for output in io_config.lighting_ios:
  dac_count = await celigo.request_analog_output_count(output.channel)
  voltage = await celigo.request_analog_output_voltage(
    output.channel,
    output.min_voltage,
    output.max_voltage,
  )
  lighting_readback[output.io_name] = {
    "dac_count": dac_count,
    "voltage": voltage,
  }
{
  "digital_inputs": await celigo.request_digital_input_bitmask(),
  "digital_outputs": await celigo.request_digital_output_bitmask(),
  "lighting": lighting_readback,
}

Control illumination through named APIs#

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.

await celigo.select_channel("brightfield")
await celigo.set_illumination_enabled(True, intensity_percent=50)
await celigo.turn_off_illumination()
{
  "fluorescence_lamp_ready": celigo.fluorescence_lamp_ready,
  "fluorescence_warmup_remaining_s": celigo.fluorescence_warmup_remaining,
  "can_change_fluorescence_power": celigo.can_change_fluorescence_power,
}

Read camera synchronization signals#

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.

{
  "busy": await celigo.request_is_camera_busy(),
  "integrating": await celigo.request_is_camera_integrating(),
  "trigger_encoder_ticks": await celigo.request_camera_trigger_encoder_ticks(),
}

Barcode UART#

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.

try:
  barcode_response = await celigo.request_barcode()
except CeligoError as error:
  barcode_response = f"Barcode interface unavailable: {error}"
barcode_response

Opt into active diagnostics#

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.

active_report = await celigo.run_self_test(
  run_active_checks=True,
  run_motion_checks=False,
)
active_report.passed, active_report.failures, active_report.checks

Read the laser safety state#

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.

{
  "laser_commands_enabled": celigo.laser.enabled,
  "controller_laser_safety_fault": (
    await celigo.request_controller_status()
  ).has_laser_safety_fault,
}

Laser safety boundary#

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.

Laser commands require a separately constructed instrument with explicit opt-in:

laser_celigo = Celigo(
  config=config,
  usb_address=usb_address,
  lucam_sdk=str(lucam_sdk),
  allow_laser=True,
)
await laser_celigo.setup()

The guarded operations are:

await laser_celigo.laser.send_command("<vendor ASCII command>")
reply = await laser_celigo.laser.request_uart_response()
await laser_celigo.laser.fire(laser_index=0, shots=1, delay=0.0)
await laser_celigo.laser.fire_targets(
  voltage_offsets=[(0.0, 0.0)],
  laser_index=0,
  pulses=1,
  delay_between_pulses=0.0,
)
laser_center_voltages = (
  config.galvo_optical_calibration.x.laser_center_voltage,
  config.galvo_optical_calibration.y.laser_center_voltage,
)
await laser_celigo.laser.fire_grid(
  laser_index=0,
  spacing_voltages=(0.05, 0.05),
  size_voltages=(0.1, 0.1),
  center_voltages=laser_center_voltages,
  pulses=1,
  repeats=1,
)

Every guarded command rechecks controller safety immediately before acting. laser.nd_filter and laser.attenuator expose their configured motor components when present.

Stop safely#

Always stop the instrument after diagnostics. This aborts controller work, clears safe outputs, closes the camera, and releases FTDI.

await celigo.stop()