Files
scanengine-3/pybbd202/README.md
T
Thomas Ales [M S E] fc43fbe4b0 Initial commit: merge nuescan, pymso, pybbd202, and pypewpewhops into scanengine-3
- Merged four separate hardware control projects into unified platform
- Created unified requirements.txt with all dependencies
- Added comprehensive .gitignore
- Added project overview README

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 20:11:31 -06:00

17 KiB

BBD202/203 Motion Controller Library

Python library for controlling Thorlabs BBD202/BBD203 motion controllers via FTDI interface using the APT protocol.

Features

  • Full control of X and Y axes
  • Absolute and relative positioning
  • Configurable velocity and acceleration
  • Automatic position tracking
  • Status monitoring with convenient properties
  • Thread-safe operation
  • Context manager support for automatic cleanup

Requirements

The FTDI D2XX driver is required for this code to work correctly. You will need to disable linux's ftdi_sio module in order to use it. I am not sure why, and I have no intent of diagnosing it.

pip install pyftdi

Quick Start

from bbd203_controller import MotionController

# Connect using context manager (automatic cleanup)
with MotionController() as mc:
    # Print hardware information
    print(f"Model: {mc.get_model()}")
    print(f"Serial: {mc.get_serial_number()}")
    print(f"Firmware: {mc.get_firmware_version()}")

    # Enable and home X-axis
    mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
    mc.home_x_axis(timeout=20.0)

    # Move to absolute position
    mc.set_velocity_params(mc.DEST_X_AXIS,
                          min_velocity=0.0,
                          acceleration=100.0,
                          max_velocity=50.0)
    mc.set_move_abs_params(mc.DEST_X_AXIS, absolute_position=25.0)
    result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)

    print(f"Final position: {result['position']:.3f} mm")

Connection Management

Basic Connection

from bbd203_controller import MotionController

# Manual connection
mc = MotionController()
mc.connect()

# Use the controller...

mc.disconnect()
# Automatic connection and cleanup
with MotionController() as mc:
    # Use the controller...
    pass  # Automatically disconnects when exiting context

Custom FTDI URL

mc = MotionController(url='ftdi://0x0403:0xfaf0/1', baudrate=115200)

Axis Control

Enabling Axes

# Enable X-axis
mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)

# Enable Y-axis
mc.set_channel_enable_state(mc.DEST_Y_AXIS, enabled=True)

# Check if enabled
is_enabled = mc.get_channel_enable_state(mc.DEST_X_AXIS)

Homing

# Home X-axis (blocks until complete)
if mc.home_x_axis(timeout=20.0):
    print(f"X-axis homed at position: {mc.position_x} mm")
else:
    print("Homing timed out")

# Home Y-axis
mc.home_y_axis(timeout=20.0)

After homing, the position automatically resets to 0 mm.

Motion Control

Setting Velocity Parameters

# Set velocity parameters for X-axis
mc.set_velocity_params(
    mc.DEST_X_AXIS,
    min_velocity=0.0,      # mm/s
    acceleration=100.0,    # mm/s²
    max_velocity=50.0      # mm/s
)

# Get current velocity parameters
params = mc.get_velocity_params(mc.DEST_X_AXIS)
print(f"Max velocity: {params['max_velocity']:.2f} mm/s")
print(f"Acceleration: {params['acceleration']:.2f} mm/s²")

Setting Acceleration Only

# Change just the acceleration, preserving velocity settings
mc.set_acceleration(mc.DEST_X_AXIS, 75.0)

# Get just the acceleration value
accel = mc.get_acceleration(mc.DEST_X_AXIS)

Absolute Moves

# Move to absolute position
mc.set_move_abs_params(mc.DEST_X_AXIS, absolute_position=30.0)
result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)

if result:
    print(f"Moved to: {result['position']:.3f} mm")
    print(f"Status: 0x{result['status_bits']:08X}")

Relative Moves

# Move relative to current position
mc.set_move_rel_params(mc.DEST_X_AXIS, relative_distance=5.0)
result = mc.move_relative(mc.DEST_X_AXIS, timeout=30.0)

# Move backwards
mc.set_move_rel_params(mc.DEST_X_AXIS, relative_distance=-2.5)
mc.move_relative(mc.DEST_X_AXIS, timeout=30.0)

Stopping Motion

# Controlled stop (gradual deceleration)
mc.stop_x_axis(stop_mode=mc.StopMode.CONTROLLED, wait_for_stopped=True)

# Immediate stop
mc.stop_x_axis(stop_mode=mc.StopMode.IMMEDIATE, wait_for_stopped=True)

# Stop both axes simultaneously
results = mc.stop_all_axes(stop_mode=mc.StopMode.CONTROLLED)

Position Tracking

Reading Current Position

# Query position from controller (blocking)
position = mc.get_position(mc.DEST_X_AXIS, timeout=5.0)
print(f"X position: {position:.3f} mm")

# Access cached position (non-blocking)
x_pos = mc.position_x
y_pos = mc.position_y

# Get encoder counts (raw values)
x_counts = mc.encoder_count_x

Status Monitoring

Using Status Properties

# Check various status flags
print(f"X-axis enabled: {mc.is_enabled_x}")
print(f"X-axis homed: {mc.is_homed_x}")
print(f"X-axis in motion: {mc.is_in_motion_x}")
print(f"X-axis settled: {mc.is_settled_x}")
print(f"X-axis has errors: {mc.has_errors_x}")
print(f"Power OK: {mc.power_ok_x}")

Decoding Status Bits

result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)

if result:
    status_bits = result['status_bits']

    # Get human-readable description
    description = MotionController.get_status_description(status_bits)
    print(description)

    # Check for errors
    if MotionController.has_errors(status_bits):
        print("ERROR: Motion completed with errors!")

    # Check motion state
    if MotionController.is_settled(status_bits):
        print("Stage is settled at target position")

Available Status Checks

  • is_enabled_x / is_enabled_y - Motor output enabled
  • is_homed_x / is_homed_y - Axis has been homed
  • is_homing_x / is_homing_y - Currently homing
  • is_in_motion_x / is_in_motion_y - Currently moving
  • is_settled_x / is_settled_y - Settled at target
  • is_tracking_x / is_tracking_y - Within tracking window
  • is_connected_x / is_connected_y - Motor recognized
  • has_errors_x / has_errors_y - Any error condition
  • power_ok_x / power_ok_y - Power supply OK
  • is_active_x / is_active_y - Executing motion command
  • at_cw_limit_x / at_cw_limit_y - At clockwise limit
  • at_ccw_limit_x / at_ccw_limit_y - At counter-clockwise limit

Multi-Axis Operations

Simultaneous Moves (Using Threading)

import threading

def move_x():
    mc.set_move_abs_params(mc.DEST_X_AXIS, 50.0)
    mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)

def move_y():
    mc.set_move_abs_params(mc.DEST_Y_AXIS, 30.0)
    mc.move_absolute(mc.DEST_Y_AXIS, timeout=30.0)

# Start both moves in parallel
x_thread = threading.Thread(target=move_x)
y_thread = threading.Thread(target=move_y)

x_thread.start()
y_thread.start()

# Wait for both to complete
x_thread.join()
y_thread.join()

print(f"Final position: ({mc.position_x:.2f}, {mc.position_y:.2f}) mm")

Hardware Information

with MotionController() as mc:
    # Individual fields
    print(f"Serial Number: {mc.get_serial_number()}")
    print(f"Model: {mc.get_model()}")
    print(f"Firmware: {mc.get_firmware_version()}")
    print(f"Hardware Version: {mc.get_hw_version()}")
    print(f"Number of Channels: {mc.get_num_channels()}")

    # All info at once
    info = mc.get_hw_info()
    for key, value in info.items():
        print(f"{key}: {value}")

Complete Examples

Example 1: Simple Linear Move

from bbd203_controller import MotionController
import time

with MotionController() as mc:
    # Enable and home X-axis
    mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
    time.sleep(0.5)

    print("Homing X-axis...")
    mc.home_x_axis(timeout=20.0)
    print(f"Homed at {mc.position_x} mm")

    # Set velocity for smooth motion
    mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, 50.0, 25.0)

    # Move to 40mm
    print("Moving to 40mm...")
    mc.set_move_abs_params(mc.DEST_X_AXIS, 40.0)
    result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)

    if result and not mc.has_errors_x:
        print(f"Successfully moved to {result['position']:.3f} mm")
    else:
        print("Move failed or has errors")

Example 2: Square Pattern with Two Axes

from bbd203_controller import MotionController
import threading
import time

def move_to_position(mc, x, y, label):
    """Move to (x, y) with both axes moving simultaneously."""
    print(f"Moving to {label}: ({x}, {y}) mm")

    # Set parameters for both axes
    mc.set_move_abs_params(mc.DEST_X_AXIS, x)
    mc.set_move_abs_params(mc.DEST_Y_AXIS, y)
    time.sleep(0.1)

    # Execute moves in parallel
    results = [None, None]

    def move_x():
        results[0] = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)

    def move_y():
        results[1] = mc.move_absolute(mc.DEST_Y_AXIS, timeout=30.0)

    x_thread = threading.Thread(target=move_x)
    y_thread = threading.Thread(target=move_y)

    x_thread.start()
    y_thread.start()
    x_thread.join()
    y_thread.join()

    if results[0] and results[1]:
        print(f"  Reached ({results[0]['position']:.2f}, {results[1]['position']:.2f}) mm")
        return True
    return False

with MotionController() as mc:
    # Enable both axes
    mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
    mc.set_channel_enable_state(mc.DEST_Y_AXIS, enabled=True)
    time.sleep(0.5)

    # Home both axes
    print("Homing axes...")
    mc.home_x_axis(timeout=20.0)
    mc.home_y_axis(timeout=20.0)

    # Set velocity for both axes
    velocity = 50.0
    acceleration = 100.0
    mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, acceleration, velocity)
    mc.set_velocity_params(mc.DEST_Y_AXIS, 0.0, acceleration, velocity)

    # Define 20mm square centered at (55, 37.5)
    center_x, center_y = 55.0, 37.5
    half_size = 10.0

    waypoints = [
        (center_x - half_size, center_y - half_size, "Bottom Left"),
        (center_x + half_size, center_y - half_size, "Bottom Right"),
        (center_x + half_size, center_y + half_size, "Top Right"),
        (center_x - half_size, center_y + half_size, "Top Left"),
        (center_x, center_y, "Center"),
    ]

    # Execute square pattern
    for x, y, label in waypoints:
        if not move_to_position(mc, x, y, label):
            print(f"Failed at {label}")
            break
        time.sleep(0.5)

    print("Square pattern complete!")

Example 3: Velocity Ramping Test

from bbd203_controller import MotionController
import time

with MotionController() as mc:
    mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
    time.sleep(0.5)
    mc.home_x_axis(timeout=20.0)

    # Test at different velocities
    test_velocities = [10.0, 25.0, 50.0, 100.0]
    move_distance = 20.0

    for velocity in test_velocities:
        print(f"\n--- Testing at {velocity} mm/s ---")

        # Set velocity parameters
        mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, 100.0, velocity)

        # Move forward
        mc.set_move_abs_params(mc.DEST_X_AXIS, move_distance)
        start_time = time.time()
        result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)
        elapsed = time.time() - start_time

        if result:
            print(f"  Moved {move_distance}mm in {elapsed:.2f}s")
            print(f"  Average speed: {move_distance/elapsed:.2f} mm/s")

        time.sleep(0.5)

        # Move back to start
        mc.set_move_abs_params(mc.DEST_X_AXIS, 0.0)
        mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)
        time.sleep(0.5)

Example 4: Position Monitoring During Move

from bbd203_controller import MotionController
import threading
import time

with MotionController() as mc:
    mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
    time.sleep(0.5)
    mc.home_x_axis(timeout=20.0)

    # Set slow velocity for visible monitoring
    mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, 50.0, 10.0)

    # Start move in background thread
    move_complete = threading.Event()

    def do_move():
        mc.set_move_abs_params(mc.DEST_X_AXIS, 50.0)
        mc.move_absolute(mc.DEST_X_AXIS, timeout=60.0)
        move_complete.set()

    move_thread = threading.Thread(target=do_move)
    move_thread.start()

    # Monitor position while moving
    print("Position monitoring:")
    while not move_complete.is_set():
        # Request current position
        pos = mc.get_position(mc.DEST_X_AXIS, timeout=1.0)
        if pos is not None:
            print(f"  Current position: {pos:.3f} mm, "
                  f"In motion: {mc.is_in_motion_x}, "
                  f"Settled: {mc.is_settled_x}")
        time.sleep(0.5)

    move_thread.join()
    print(f"Move complete! Final position: {mc.position_x:.3f} mm")

Constants and Enumerations

Axis Destinations

mc.DEST_CONTROLLER  # 0x11 - Controller/motherboard
mc.DEST_X_AXIS      # 0x21 - X-axis
mc.DEST_Y_AXIS      # 0x22 - Y-axis

Stop Modes

from bbd203_controller import StopMode

StopMode.IMMEDIATE   # 1 - Instant stop
StopMode.CONTROLLED  # 2 - Controlled deceleration (default)

Jog Modes

from bbd203_controller import JogMode

JogMode.CONTINUOUS   # 1 - Continuous jogging
JogMode.SINGLE_STEP  # 2 - Single step jogging

Channel Enable States

from bbd203_controller import ChannelEnableState

ChannelEnableState.DISABLED  # 0x02
ChannelEnableState.ENABLED   # 0x01

Scaling Factors

The library handles all unit conversions automatically:

  • Position: 20,000 encoder counts per mm
  • Velocity: 13,421.77 counts per mm/s
  • Acceleration: 13.744 counts per mm/s²

Error Handling

from bbd203_controller import MotionController

try:
    with MotionController() as mc:
        # Invalid axis destination
        mc.set_channel_enable_state(0x99, enabled=True)
except ValueError as e:
    print(f"ValueError: {e}")

try:
    with MotionController() as mc:
        mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
        mc.home_x_axis(timeout=5.0)  # Too short timeout

        if not mc.is_homed_x:
            print("Homing failed - axis not homed")
except Exception as e:
    print(f"Error: {e}")

Advanced Features

Message Callbacks

from bbd203_controller import MotionController, AptMessage, MsgId

def on_move_stopped(msg: AptMessage):
    print(f"Axis stopped unexpectedly!")
    print(f"Source: 0x{msg.source:02X}")

with MotionController() as mc:
    # Register callback for stop events
    mc.register_callback(MsgId.MOT_MOVE_STOPPED, on_move_stopped)

    # Your motion code here...

    # Unregister when done
    mc.unregister_callback(MsgId.MOT_MOVE_STOPPED, on_move_stopped)

Direct Message Access

# Wait for a specific message type
msg = mc.wait_for_message(MsgId.MOT_MOVE_COMPLETED, timeout=30.0)

# Get next message from queue
msg = mc.get_message(timeout=0.1)

# Get all queued messages
messages = mc.get_all_messages()

Manual Connection Control

mc = MotionController()

# Connect with updates disabled
mc.connect(enable_updates=False)

# Manually start/stop status updates
mc.start_update_messages()
# ...
mc.stop_update_messages()

mc.disconnect()

Tips for Linear Scans

For performing linear scans at controlled speeds:

  1. Set velocity parameters before each scan to ensure consistent motion
  2. Use absolute moves with pre-calculated waypoints for accuracy
  3. For continuous scanning: Execute moves sequentially without waiting
  4. For synchronized 2-axis moves: Use threading (see examples above)
  5. Monitor position during moves if needed for data acquisition timing

Simple 1D Linear Scan

with MotionController() as mc:
    mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
    mc.home_x_axis(timeout=20.0)

    # Scan parameters
    start_pos = 10.0  # mm
    end_pos = 90.0    # mm
    step_size = 2.0   # mm
    scan_speed = 20.0 # mm/s

    # Set velocity for consistent speed
    mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, 100.0, scan_speed)

    # Execute scan
    position = start_pos
    while position <= end_pos:
        mc.set_move_abs_params(mc.DEST_X_AXIS, position)
        result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)

        if result:
            # Acquire data at this position
            print(f"Scan point at {result['position']:.3f} mm")
            # Your data acquisition code here...

        position += step_size

Troubleshooting

Controller Not Responding

If the controller stops responding after many commands:

  • The library automatically sends ACK messages every second
  • This is handled internally and should not require user intervention

Moves Timing Out

  • Increase the timeout parameter on move commands
  • Check that velocity and acceleration are set appropriately
  • Ensure the axis is enabled and homed

Position Inaccurate After Homing

  • Position automatically resets to 0 mm after homing completes
  • Always wait for homing to complete before issuing move commands
  • Check is_homed_x / is_homed_y properties to verify

Unexpected Stops

  • Register a callback for MsgId.MOT_MOVE_STOPPED to detect stop events
  • Check error flags in status bits
  • Ensure no limit switches are being triggered

License

MIT License

Author

Generated for BBD202/BBD203 motion controller control via APT protocol.