Files
scanengine-3/tests/test_bbd202_diagnostic.py
T
2026-02-09 14:40:34 -06:00

570 lines
19 KiB
Python

"""
BBD202 Stage Diagnostic Tests
Diagnostic tests for the Thorlabs MLS203/BBD202 motion controller to:
1. Check if axes are properly homed
2. Move in a 15mm square pattern while outputting position data
3. Detect stage hangs through position monitoring
4. Output response information for debugging
"""
import sys
import time
from typing import Optional
from hardware.bbd202 import MotionController, MotorStatusBits
def format_status_bits(bits: Optional[int]) -> str:
"""Format status bits as a readable string with key flags."""
if bits is None:
return "None (no status received)"
flags = []
status = MotorStatusBits(bits)
# Key flags to check
if MotorStatusBits.HOMED in status:
flags.append("HOMED")
if MotorStatusBits.HOMING in status:
flags.append("HOMING")
if MotorStatusBits.ENABLED in status:
flags.append("ENABLED")
if MotorStatusBits.SETTLED in status:
flags.append("SETTLED")
if MotorStatusBits.TRACKING in status:
flags.append("TRACKING")
if MotorStatusBits.INMOTIONCW in status:
flags.append("INMOTIONCW")
if MotorStatusBits.INMOTIONCCW in status:
flags.append("INMOTIONCCW")
if MotorStatusBits.POWEROK in status:
flags.append("POWEROK")
if MotorStatusBits.ERROR in status:
flags.append("ERROR")
if MotorStatusBits.POSITIONERROR in status:
flags.append("POSITIONERROR")
if MotorStatusBits.OVERTEMP in status:
flags.append("OVERTEMP")
if MotorStatusBits.COMMUTATIONERROR in status:
flags.append("COMMUTATIONERROR")
flag_str = ", ".join(flags) if flags else "NO_FLAGS"
return f"0x{bits:08X} [{flag_str}]"
def test_homing_status(mc: MotionController) -> bool:
"""
Test 1: Check if both axes are properly homed.
Returns True if both axes are homed, False otherwise.
"""
print("\n" + "="*70)
print("TEST 1: Checking Homing Status")
print("="*70)
# Request fresh status from both axes
print("\nRequesting status update from both axes...")
mc.request_status_update(mc.DEST_X_AXIS)
mc.request_status_update(mc.DEST_Y_AXIS)
time.sleep(0.2) # Allow time for response
# Also poll status to ensure we have latest
mc.poll_status()
time.sleep(0.1)
# Check X-axis
x_bits = mc.status_bits_x
x_homed = mc.is_homed_x
print(f"\nX-Axis Status:")
print(f" Raw status bits: {format_status_bits(x_bits)}")
print(f" is_homed_x: {x_homed}")
# Check Y-axis
y_bits = mc.status_bits_y
y_homed = mc.is_homed_y
print(f"\nY-Axis Status:")
print(f" Raw status bits: {format_status_bits(y_bits)}")
print(f" is_homed_y: {y_homed}")
# Check for errors
last_err = mc.last_error
if last_err:
print(f"\n** Last Error: {last_err}")
error_check = mc.check_for_errors()
if error_check:
print(f"** Error condition detected: {error_check}")
# Summary
print(f"\n--- Homing Status Summary ---")
if x_homed and y_homed:
print("PASS: Both axes are homed")
return True
else:
print("FAIL: One or both axes are NOT homed")
if not x_homed:
print(" -> X-axis needs homing")
if not y_homed:
print(" -> Y-axis needs homing")
return False
def test_square_pattern(mc: MotionController, side_length: float = 15.0) -> bool:
"""
Test 2: Move the stage in a square pattern.
Moves in a 15mm square starting from current position, outputting
position data at each step to detect hangs.
Pattern: Start -> +X -> +Y -> -X -> -Y (back to start)
Uses the new wait_until_settled() method for reliable motion detection.
"""
print("\n" + "="*70)
print(f"TEST 2: Square Pattern Movement ({side_length}mm)")
print("="*70)
print("Using wait_until_settled() for motion detection")
# Get starting position
mc.poll_positions()
time.sleep(0.1)
start_x = mc.position_x
start_y = mc.position_y
if start_x is None or start_y is None:
print("ERROR: Could not get initial position")
return False
print(f"\nStarting position: X={start_x:.4f}mm, Y={start_y:.4f}mm")
# Define the square corners (relative to start)
corners = [
(start_x + side_length, start_y), # Move +X
(start_x + side_length, start_y + side_length), # Move +Y
(start_x, start_y + side_length), # Move -X
(start_x, start_y), # Move -Y (back to start)
]
corner_names = ["+X edge", "+Y edge (diagonal)", "-X edge", "Return to start"]
all_passed = True
for i, (target_x, target_y) in enumerate(corners):
print(f"\n--- Move {i+1}/4: {corner_names[i]} ---")
print(f"Target: X={target_x:.4f}mm, Y={target_y:.4f}mm")
# Clear any previous error
mc.clear_last_error()
# Record start time
move_start = time.time()
# Issue move command (non-blocking)
mc.move_to_fast(x=target_x, y=target_y)
# Use poll_until_idle() in a loop to show progress
print(f"\n{'Time':>8} {'X_pos':>12} {'Y_pos':>12} {'X_err':>10} {'Y_err':>10} {'Pending'}")
print("-" * 70)
sample_count = 0
while not mc.poll_until_idle(tolerance=0.005, timeout=0.05):
elapsed = time.time() - move_start
curr_x = mc.position_x
curr_y = mc.position_y
if curr_x is not None and curr_y is not None:
x_err = curr_x - target_x
y_err = curr_y - target_y
# Print every 5th sample
if sample_count % 5 == 0:
pending = mc.get_pending_targets()
pending_str = ", ".join([f"{'X' if d==0x21 else 'Y'}" for d in pending.keys()])
print(f"{elapsed:>7.3f}s {curr_x:>12.4f} {curr_y:>12.4f} {x_err:>+10.4f} {y_err:>+10.4f} {pending_str:>8}")
sample_count += 1
# Timeout check
if elapsed > 30.0:
print(f"\n** TIMEOUT after 30s - stage may be hung!")
all_passed = False
mc.clear_pending_moves()
break
# Move complete - show final status
elapsed = time.time() - move_start
curr_x = mc.position_x
curr_y = mc.position_y
if curr_x is not None and curr_y is not None:
x_err = curr_x - target_x
y_err = curr_y - target_y
print(f"{elapsed:>7.3f}s {curr_x:>12.4f} {curr_y:>12.4f} {x_err:>+10.4f} {y_err:>+10.4f} ** DONE **")
print(f"\nMove completed in {elapsed:.3f}s")
print(f"Final position: X={curr_x:.4f}mm, Y={curr_y:.4f}mm")
print(f"Position error: X={abs(x_err)*1000:.1f}um, Y={abs(y_err)*1000:.1f}um")
# Check for errors after move
err = mc.check_for_errors()
if err:
print(f"** Error after move: {err}")
all_passed = False
last_err = mc.last_error
if last_err:
print(f"** Last error: {last_err}")
all_passed = False
# Short delay between moves
time.sleep(0.1)
# Final summary
print(f"\n--- Square Pattern Summary ---")
mc.poll_positions()
time.sleep(0.1)
final_x = mc.position_x
final_y = mc.position_y
if final_x is not None and final_y is not None:
total_x_err = abs(final_x - start_x)
total_y_err = abs(final_y - start_y)
print(f"Final position: X={final_x:.4f}mm, Y={final_y:.4f}mm")
print(f"Start position: X={start_x:.4f}mm, Y={start_y:.4f}mm")
print(f"Return error: X={total_x_err*1000:.1f}um, Y={total_y_err*1000:.1f}um")
if total_x_err > 0.05 or total_y_err > 0.05:
print("FAIL: Did not return to start position accurately (>50um)")
all_passed = False
if all_passed:
print("PASS: Square pattern completed successfully")
else:
print("FAIL: Issues detected during square pattern")
return all_passed
def print_diagnostic_info(mc: MotionController):
"""Print comprehensive diagnostic information about the controller."""
print("\n" + "="*70)
print("CONTROLLER DIAGNOSTIC INFO")
print("="*70)
try:
hw_info = mc.get_hw_info()
print(f"\nHardware Info:")
print(f" Serial Number: {hw_info['serial_number']}")
print(f" Model: {hw_info['model']}")
print(f" Firmware: {hw_info['firmware_version']}")
print(f" HW Version: {hw_info['hw_version']}")
print(f" Channels: {hw_info['num_channels']}")
print(f" Notes: {hw_info['notes']}")
except Exception as e:
print(f" Error getting hardware info: {e}")
# Request and display velocity params
print(f"\nVelocity Parameters:")
mc.send_command(0x0414, param1=0x01, dest=mc.DEST_X_AXIS) # REQ_VELPARAMS
mc.send_command(0x0414, param1=0x01, dest=mc.DEST_Y_AXIS)
time.sleep(0.1)
x_vel = mc.velocity_params_x
y_vel = mc.velocity_params_y
if x_vel:
print(f" X-Axis: max_vel={x_vel['max_velocity']:.2f}mm/s, accel={x_vel['acceleration']:.2f}mm/s²")
if y_vel:
print(f" Y-Axis: max_vel={y_vel['max_velocity']:.2f}mm/s, accel={y_vel['acceleration']:.2f}mm/s²")
def test_bay_and_channel_status(mc: MotionController) -> dict:
"""
Test bay occupancy and channel enable states.
Returns dict with diagnostic info about each axis.
"""
print("\n" + "="*70)
print("BAY AND CHANNEL DIAGNOSTICS")
print("="*70)
results = {'x': {}, 'y': {}}
# Query bay status using MGMSG_RACK_REQ_BAYUSED (0x0060)
print("\n--- Querying Bay Status (RACK_REQ_BAYUSED 0x0060) ---")
print("Sending to controller (0x11)...")
# Send bay request to controller
mc.send_command(0x0060, param1=0x00, param2=0x00, dest=0x11, source=0x01)
time.sleep(0.2)
# Check for response in queue
msgs = mc.get_all_messages()
bay_response = None
for msg in msgs:
print(f" Received: msg_id=0x{msg.msg_id:04X}, source=0x{msg.source:02X}, "
f"param1=0x{msg.param1:02X}, param2=0x{msg.param2:02X}, data={msg.data.hex() if msg.data else 'none'}")
if msg.msg_id == 0x0061: # RACK_GET_BAYUSED
bay_response = msg
if bay_response:
# param1 contains bay_ident (which bay), param2 contains state
print(f"\nBay status response: bay={bay_response.param1}, state=0x{bay_response.param2:02X}")
else:
print(" No RACK_GET_BAYUSED response received")
# Check channel enable state for X-axis (0x21)
print("\n--- X-Axis (Bay 1 / dest=0x21) Channel Status ---")
print(f" Destination address: 0x{mc.DEST_X_AXIS:02X}")
try:
x_enabled = mc.get_channel_enable_state(mc.DEST_X_AXIS, timeout=2.0)
print(f" Channel enabled: {x_enabled}")
results['x']['enabled'] = x_enabled
except Exception as e:
print(f" Error querying channel state: {e}")
results['x']['enabled'] = None
# Get X position to verify communication
x_pos = mc.get_position(mc.DEST_X_AXIS, timeout=2.0)
print(f" Current position: {x_pos:.4f}mm" if x_pos is not None else " Position query failed!")
results['x']['position'] = x_pos
results['x']['communicating'] = x_pos is not None
# Check channel enable state for Y-axis (0x22)
print("\n--- Y-Axis (Bay 2 / dest=0x22) Channel Status ---")
print(f" Destination address: 0x{mc.DEST_Y_AXIS:02X}")
try:
y_enabled = mc.get_channel_enable_state(mc.DEST_Y_AXIS, timeout=2.0)
print(f" Channel enabled: {y_enabled}")
results['y']['enabled'] = y_enabled
except Exception as e:
print(f" Error querying channel state: {e}")
results['y']['enabled'] = None
# Get Y position to verify communication
y_pos = mc.get_position(mc.DEST_Y_AXIS, timeout=2.0)
print(f" Current position: {y_pos:.4f}mm" if y_pos is not None else " Position query failed!")
results['y']['position'] = y_pos
results['y']['communicating'] = y_pos is not None
return results
def test_y_axis_move_verbose(mc: MotionController) -> bool:
"""
Test Y-axis movement using the new wait_until_settled() method.
Sends a small Y-axis move and monitors progress.
"""
print("\n" + "="*70)
print("Y-AXIS VERBOSE MOVE TEST")
print("="*70)
print("Using wait_until_settled() for motion detection")
# Get current Y position
mc.poll_positions()
time.sleep(0.1)
start_y = mc.position_y
if start_y is None:
print("ERROR: Cannot get Y position")
return False
print(f"\nCurrent Y position: {start_y:.4f}mm")
# Target: move 5mm in Y
target_y = start_y + 5.0
print(f"Target Y position: {target_y:.4f}mm")
# Issue move command using move_to_fast (which tracks pending moves)
print("\n--- Issuing Y-axis move via move_to_fast() ---")
mc.move_to_fast(y=target_y)
# Show pending moves
pending = mc.get_pending_targets()
print(f" Pending moves: {pending}")
print(f" is_move_pending(): {mc.is_move_pending()}")
# Monitor using poll_until_idle
print("\n--- Monitoring Y-axis with poll_until_idle() ---")
print(f"{'Time':>6} {'Y_pos':>10} {'Y_err':>10} {'Pending'}")
print("-" * 45)
start_time = time.time()
movement_detected = False
while not mc.poll_until_idle(tolerance=0.005, timeout=0.05):
elapsed = time.time() - start_time
curr_y = mc.position_y
if curr_y is not None:
y_err = curr_y - target_y
pending = "Y" if mc.is_move_pending() else "-"
print(f"{elapsed:>5.2f}s {curr_y:>10.4f} {y_err:>+10.4f} {pending:>8}")
if abs(curr_y - start_y) > 0.01:
movement_detected = True
if elapsed > 10.0:
print("\n** TIMEOUT after 10s **")
mc.clear_pending_moves()
break
# Final status
elapsed = time.time() - start_time
final_y = mc.position_y
print(f"\n--- Y-Axis Move Summary ---")
print(f"Start position: {start_y:.4f}mm")
print(f"Target position: {target_y:.4f}mm")
print(f"Final position: {final_y:.4f}mm" if final_y else "Final position: unknown")
print(f"Move time: {elapsed:.3f}s")
if final_y is not None:
distance_moved = abs(final_y - start_y)
print(f"Distance moved: {distance_moved:.4f}mm")
if distance_moved < 0.01:
print("\n** FAIL: Y-axis did not move at all! **")
print("Possible causes:")
print(" 1. Y-axis channel is disabled")
print(" 2. Y-axis motor driver issue")
print(" 3. Command not reaching Y-axis bay")
print(" 4. Mechanical obstruction")
return False
elif abs(final_y - target_y) < 0.05:
print("\n** PASS: Y-axis moved to target **")
return True
else:
print(f"\n** PARTIAL: Y-axis moved but not to target **")
return False
return movement_detected
def test_enable_and_move_y(mc: MotionController) -> bool:
"""
Explicitly enable Y-axis channel and attempt movement.
"""
print("\n" + "="*70)
print("ENABLE Y-AXIS AND MOVE TEST")
print("="*70)
# First, explicitly enable Y-axis channel
print("\n--- Enabling Y-axis channel (MOD_SET_CHANENABLESTATE) ---")
print(f" Sending to dest=0x{mc.DEST_Y_AXIS:02X}, enable=True")
try:
mc.set_channel_enable_state(mc.DEST_Y_AXIS, enabled=True)
time.sleep(0.2)
print(" Enable command sent")
except Exception as e:
print(f" Error sending enable: {e}")
# Verify it's enabled
try:
y_enabled = mc.get_channel_enable_state(mc.DEST_Y_AXIS, timeout=2.0)
print(f" Channel enabled state: {y_enabled}")
except Exception as e:
print(f" Error checking state: {e}")
# Request fresh status
mc.request_status_update(mc.DEST_Y_AXIS)
time.sleep(0.2)
y_bits = mc.status_bits_y
print(f" Status bits: {format_status_bits(y_bits)}")
# Now try the verbose move test
return test_y_axis_move_verbose(mc)
def main():
print("="*70)
print("BBD202 Stage Diagnostic Test")
print("="*70)
mc = None
try:
# Connect to controller
print("\nConnecting to BBD202 controller...")
mc = MotionController()
mc.connect(enable_updates=True)
print("Connected!")
# Print diagnostic info
print_diagnostic_info(mc)
# Run bay and channel diagnostics
channel_results = test_bay_and_channel_status(mc)
# Run homing status test
homed = test_homing_status(mc)
if not homed:
print("\n" + "-"*70)
response = input("Axes not homed. Home now? (y/n): ").strip().lower()
if response == 'y':
print("\nHoming X-axis...")
x_result = mc.home_x_axis(timeout=30.0)
print(f"X-axis homing: {'SUCCESS' if x_result else 'FAILED'}")
print("\nHoming Y-axis...")
y_result = mc.home_y_axis(timeout=30.0)
print(f"Y-axis homing: {'SUCCESS' if y_result else 'FAILED'}")
if not (x_result and y_result):
print("\nHoming failed. Cannot continue with movement test.")
return 1
# Re-check homing status
test_homing_status(mc)
else:
print("\nSkipping movement tests (axes not homed)")
return 1
# Run Y-axis verbose test first to diagnose the issue
y_axis_ok = test_enable_and_move_y(mc)
# If Y-axis failed, skip the square pattern
if not y_axis_ok:
print("\n** Y-axis movement failed - skipping square pattern test **")
square_ok = False
else:
# Run square pattern test
square_ok = test_square_pattern(mc, side_length=15.0)
# Final summary
print("\n" + "="*70)
print("DIAGNOSTIC SUMMARY")
print("="*70)
print(f"X-Axis Channel: {'OK' if channel_results['x'].get('communicating') else 'FAIL'}")
print(f"Y-Axis Channel: {'OK' if channel_results['y'].get('communicating') else 'FAIL'}")
print(f"Homing Status: {'PASS' if homed else 'FAIL'}")
print(f"Y-Axis Move: {'PASS' if y_axis_ok else 'FAIL'}")
print(f"Square Pattern: {'PASS' if square_ok else 'FAIL/SKIPPED'}")
if homed and y_axis_ok and square_ok:
print("\nAll tests PASSED")
return 0
else:
print("\nSome tests FAILED - see details above")
return 1
except KeyboardInterrupt:
print("\n\nTest interrupted by user")
return 1
except Exception as e:
print(f"\nError during test: {e}")
import traceback
traceback.print_exc()
return 1
finally:
if mc is not None:
print("\nDisconnecting from controller...")
mc.disconnect()
print("Disconnected")
if __name__ == "__main__":
sys.exit(main())