fc43fbe4b0
- 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>
669 lines
17 KiB
Markdown
669 lines
17 KiB
Markdown
# 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.
|
|
|
|
```bash
|
|
pip install pyftdi
|
|
```
|
|
|
|
## Quick Start
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
from bbd203_controller import MotionController
|
|
|
|
# Manual connection
|
|
mc = MotionController()
|
|
mc.connect()
|
|
|
|
# Use the controller...
|
|
|
|
mc.disconnect()
|
|
```
|
|
|
|
### Using Context Manager (Recommended)
|
|
|
|
```python
|
|
# Automatic connection and cleanup
|
|
with MotionController() as mc:
|
|
# Use the controller...
|
|
pass # Automatically disconnects when exiting context
|
|
```
|
|
|
|
### Custom FTDI URL
|
|
|
|
```python
|
|
mc = MotionController(url='ftdi://0x0403:0xfaf0/1', baudrate=115200)
|
|
```
|
|
|
|
## Axis Control
|
|
|
|
### Enabling Axes
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
mc.DEST_CONTROLLER # 0x11 - Controller/motherboard
|
|
mc.DEST_X_AXIS # 0x21 - X-axis
|
|
mc.DEST_Y_AXIS # 0x22 - Y-axis
|
|
```
|
|
|
|
### Stop Modes
|
|
|
|
```python
|
|
from bbd203_controller import StopMode
|
|
|
|
StopMode.IMMEDIATE # 1 - Instant stop
|
|
StopMode.CONTROLLED # 2 - Controlled deceleration (default)
|
|
```
|
|
|
|
### Jog Modes
|
|
|
|
```python
|
|
from bbd203_controller import JogMode
|
|
|
|
JogMode.CONTINUOUS # 1 - Continuous jogging
|
|
JogMode.SINGLE_STEP # 2 - Single step jogging
|
|
```
|
|
|
|
### Channel Enable States
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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.
|