diff --git a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md b/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md new file mode 100644 index 00000000000..d6e13a1191b --- /dev/null +++ b/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md @@ -0,0 +1,24 @@ +# Centrifuges + +Centrifuges are controlled by the {class}`~pylabrobot.centrifuge.centrifuge.Centrifuge` class. This class takes a backend as an argument. The backend is responsible for communicating with the centrifuge and is specific to the hardware being used. + +The {class}`~pylabrobot.centrifuge.centrifuge.Centrifuge` class has a number of methods for controlling the centrifuge. These are: + +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.open_door`: Open the centrifuge door. +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.close_door`: Close the centrifuge door. +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.lock_door`: Lock the centrifuge door. +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.unlock_door`: Unlock the centrifuge door. +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.lock_bucket`: Lock centrifuge buckets. +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.unlock_bucket`: Unlock centrifuge buckets. +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket1`: Rotate to Bucket 1. +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket2`: Rotate to Bucket 2. +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.rotate_distance`: Rotate the buckets a specified distance (8000 = 360 degrees). +- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.start_spin_cycle`: Start centrifuge spin cycle. + +PLR supports the following centrifuges: + +```{toctree} +:maxdepth: 1 + +agilent_vspin +``` diff --git a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.rst b/docs/user_guide/01_material-handling/centrifuge/_centrifuge.rst deleted file mode 100644 index b3bc9849d0e..00000000000 --- a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.rst +++ /dev/null @@ -1,9 +0,0 @@ -Centrifuge -========== - -PLR supports the following centrifuges: - -.. toctree:: - :maxdepth: 1 - - agilent_vspin diff --git a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.md b/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.md index a86a5e480be..e872bc79a67 100644 --- a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.md +++ b/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.md @@ -1,32 +1,20 @@ # Agilent VSpin -PyLabRobot supports the following centrifuges: - -- {ref}`VSpin ` - -Centrifuges are controlled by the {class}`~pylabrobot.centrifuge.centrifuge.Centrifuge` class. This class takes a backend as an argument. The backend is responsible for communicating with the centrifuge and is specific to the hardware being used. +The VSpin centrifuge is controlled by the {class}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend` class. ```python -from pylabrobot.centrifuge import Centrifuge -backend = SomeCentrifugeBackend() -pr = Centrifuge(backend=backend) -await pr.setup() +from pylabrobot.centrifuge import Centrifuge, VSpinBackend +await cf.setup() +cf = Centrifuge(name = "centrifuge", backend = VSpinBackend(device_id="YOUR_FTDI_ID_HERE"), size_x= 1, size_y=1, size_z=1) ``` -The {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.setup` method is used to initialize the centrifuge. This is where the backend will connect to the centrifuge and perform any necessary initialization. +You need to calibrate the bucket 1 position for every vspin. You can do that by opening the door (`cf.open_door()`), manually rotating the buckets to align bucket 1 with the door, and then setting bucket 1 position to the current position with `cf.backend.set_bucket_1_position_to_current()`. This will save the calibration for the current centrifuge to disk (based on the usb serial number). -The {class}`~pylabrobot.centrifuge.centrifuge.Centrifuge` class has a number of methods for controlling the centrifuge. These are: - -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.open_door`: Open the centrifuge door. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.close_door`: Close the centrifuge door. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.lock_door`: Lock the centrifuge door. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.unlock_door`: Unlock the centrifuge door. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.lock_bucket`: Lock centrifuge buckets. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.unlock_bucket`: Unlock centrifuge buckets. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket1`: Rotate to Bucket 1. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket2`: Rotate to Bucket 2. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.rotate_distance`: Rotate the buckets a specified distance (8000 = 360 degrees). -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.start_spin_cycle`: Start centrifuge spin cycle. +```python +await cf.open_door() +# Manually rotate buckets to align bucket 1 with door +await cf.backend.set_bucket_1_position_to_current() +``` Start spin cycle: @@ -34,26 +22,74 @@ Start spin cycle: await cf.start_spin_cycle(g = 800, duration = 60) ``` -(VSpin)= +Going to buckets: -## VSpin +```python +await cf.go_to_bucket1() +await cf.go_to_bucket2() +``` -The VSpin centrifuge is controlled by the {class}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend` class. +## Loader + +The VSpin can optionally be used with a loader (called Access2). The loader is optional because you can also use a robotic arm like an iSWAP to move a plate directly into the centrifuge. + +Here's how to use the loader: ```python -from pylabrobot.centrifuge import Centrifuge, VSpinBackend -cf = Centrifuge(name = 'centrifuge', backend = VSpinBackend(bucket_1_position=0), size_x= 1, size_y=1, size_z=1) +import asyncio + +from pylabrobot.centrifuge import Access2, VSpinBackend +v = VSpinBackend(device_id="YOUR_VSPIN_FTDI_ID_HERE") +centrifuge, loader = Access2(name="name", vspin=v, device_id="YOUR_LOADER_FTDI_ID_HERE") + +# initialize the centrifuge and loader in parallel +await asyncio.gather( + centrifuge.setup(), + loader.setup() +) + +# go to a bucket and open the door before loading +await centrifuge.go_to_bucket1() +await centrifuge.open_door() + +# assign a plate to the loader before loading. This can also be done implicitly by for example +# lh.move_plate(plate, loader) +from pylabrobot.resources import Cor_96_wellplate_360ul_Fb +plate = Cor_96_wellplate_360ul_Fb(name="plate") +loader.assign_child_resource(plate) + +# load and unload the plate +await loader.load() +await loader.unload() ``` -### Installation +## Installation The VSpin centrifuge connects to your system via a COM port. Integrating it with `pylabrobot` library requires some setup. Follow this guide to get started. -#### 1. Preparing Your Environment +### 1. Installing libftdi -- Windows: +#### macOS -##### Find Your Python Directory +Install libftdi using [Homebrew](https://brew.sh/): + +```bash +brew install libftdi +``` + +#### Linux + +Debian (rpi) / Ubuntu etc: + +```bash +sudo apt-get install libftdi-dev +``` + +Other distros have similar packages. + +#### Windows + +**Find Your Python Directory** To use the necessary FTDI `.dll` files, you need to locate your Python environment: @@ -66,7 +102,7 @@ To use the necessary FTDI `.dll` files, you need to locate your Python environme 2. This will print a path, e.g., `C:\Python39\python.exe`. 3. Navigate to the `Scripts` folder in the same directory as `python.exe`. -##### **Download FTDI DLLs** +**Download FTDI DLLs** Download the required `.dll` files from the following link: [FTDI Development Kit](https://sourceforge.net/projects/picusb/files/libftdi1-1.5_devkit_x86_x64_19July2020.zip/download) (link will start download). @@ -77,31 +113,11 @@ Download the required `.dll` files from the following link: - `libftdi1.dll` - `libusb-1.0.dll` -##### Place DLLs in Python Scripts Folder +**Place DLLs in Python Scripts Folder** Paste the copied `.dll` files into the `Scripts` folder of your Python environment. This enables Python to communicate with FTDI devices. -- macOS: - -Install libftdi using [Homebrew](https://brew.sh/): - -```bash -brew install libftdi -``` - -- Linux: - -Debian (rpi) / Ubuntu etc: - -```bash -sudo apt-get install libftdi-dev -``` - -Other distros may have similar packages. - -#### 2. Configuring the Driver with Zadig - -- **This step is only required on Windows.** +**Configuring the Driver with Zadig** Use Zadig to replace the default driver of the VSpin device with `libusbk`: @@ -118,7 +134,7 @@ Use Zadig to replace the default driver of the VSpin device with `libusbk`: > **Note:** If you need to revert to the original driver for tools like the Agilent Centrifuge Config Tool, go to **Device Manager** and uninstall the `libusbk` driver. The default driver will reinstall automatically. -#### 3. Finding the FTDI ID +### 2. Finding the FTDI ID To interact with the centrifuge programmatically, you need its FTDI device ID. Use the following steps to find it: @@ -132,57 +148,4 @@ To interact with the centrifuge programmatically, you need its FTDI device ID. U ``` 3. Copy the ID (`FTE0RJ5T` or your equivalent). -#### **4. Setting Up the Centrifuge** - -Use the following code to configure the centrifuge in Python: - -```python -from pylabrobot.centrifuge import Centrifuge, VSpinBackend - -# Replace with your specific FTDI device ID and bucket position for profile in Agilent Centrifuge Config Tool. -backend = VSpinBackend(bucket_1_position=6969, device_id="XXXXXXXX") -centrifuge = Centrifuge( - backend=backend, - name="centrifuge", - size_x=1, size_y=1, size_z=1 -) - -# Initialize the centrifuge. -await centrifuge.setup() -``` - You’re now ready to use your VSpin centrifuge with `pylabrobot`! - -### Loader - -The VSpin can optionally be used with a loader (called Access2). The loader is optional because you can also use a robotic arm like an iSWAP to move a plate directly into the centrifuge. - -Here's how to use the loader: - -```python -import asyncio - -from pylabrobot.centrifuge import Access2, VSpinBackend -v = VSpinBackend(device_id="FTE1YWTI", bucket_1_position=1314) # bucket 1 position is empirically determined -centrifuge, loader = Access2(name="name", vspin=v, device_id="FTE1YZC5") - -# initialize the centrifuge and loader in parallel -await asyncio.gather( - centrifuge.setup(), - loader.setup() -) - -# go to a bucket and open the door before loading -await centrifuge.go_to_bucket1() -await centrifuge.open_door() - -# assign a plate to the loader before loading. This can also be done implicitly by for example -# lh.move_plate(plate, loader) -from pylabrobot.resources import Cor_96_wellplate_360ul_Fb -plate = Cor_96_wellplate_360ul_Fb(name="plate") -loader.assign_child_resource(plate) - -# load and unload the plate -await loader.load() -await loader.unload() -``` diff --git a/pylabrobot/centrifuge/vspin_backend.py b/pylabrobot/centrifuge/vspin_backend.py index 824559d0aa3..52aab03264a 100644 --- a/pylabrobot/centrifuge/vspin_backend.py +++ b/pylabrobot/centrifuge/vspin_backend.py @@ -1,6 +1,9 @@ import asyncio +import json import logging +import os import time +import warnings from typing import Optional, Union from pylabrobot.io.ftdi import FTDI @@ -125,24 +128,56 @@ async def unload(self): await self.send_command(bytes.fromhex("11050003002000006bd4")) +_vspin_bucket_calibrations_path = os.path.join( + os.path.expanduser("~"), + ".pylabrobot", + "vspin_bucket_calibrations.json", +) + + +def _load_vspin_calibrations(device_id: str) -> Optional[int]: + if not os.path.exists(_vspin_bucket_calibrations_path): + return None + with open(_vspin_bucket_calibrations_path, "r") as f: + return json.load(f).get(device_id) # type: ignore + + +def _save_vspin_calibrations(device_id, remainder: int): + if os.path.exists(_vspin_bucket_calibrations_path): + with open(_vspin_bucket_calibrations_path, "r") as f: + data = json.load(f) + else: + data = {} + data[device_id] = remainder + os.makedirs(os.path.dirname(_vspin_bucket_calibrations_path), exist_ok=True) + with open(_vspin_bucket_calibrations_path, "w") as f: + json.dump(data, f) + + +FULL_ROTATION: int = 8000 + + class VSpinBackend(CentrifugeBackend): """Backend for the Agilent Centrifuge. Note that this is not a complete implementation.""" - def __init__(self, calibration_offset: int, device_id: Optional[str] = None): + def __init__(self, device_id: str): """ Args: - device_id: The libftdi id for the centrifuge. Find using - `python3 -m pylibftdi.examples.list_devices` - calibration_offset: The number of steps after the home (setup) position to reach the bucket. - To find this value, start with an arbitrary value, call `setup()` and then `get_position()`. - Then, move to the bucket by manually pushing it and call `get_position()` again. The - difference between the two values is the calibration offset. The reason we need an offset / - relative distance is the setup position will change between runs. + device_id: The libftdi id for the centrifuge. Find using `python -m pylibftdi.examples.list_devices` """ self.io = FTDI(device_id=device_id) - self.calibration_offset = calibration_offset - self.homing_position = 0 + # TODO: can device_id be loaded? + self.device_id = device_id + self._bucket_1_remainder: Optional[int] = None + if device_id is not None: + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + if self._bucket_1_remainder is None: + warnings.warn( + f"No calibration found for VSpin with device id {device_id}. " + "Please set the bucket 1 position using `set_bucket_1_position_to_current` method after setup.", + UserWarning, + ) async def setup(self): await self.io.setup() @@ -231,7 +266,7 @@ async def setup(self): await self.send(b"\xaa\x01\x0b\x0c") await self.send(b"\xaa\x01\x0e\x0f") await self.send(b"\xaa\x01\xe6\xc8\x00\xb0\x04\x96\x00\x0f\x00\x4b\x00\xa0\x0f\x05\x00\x07") - new_position = (self.homing_position + 8000).to_bytes(4, byteorder="little") + new_position = (0).to_bytes(4, byteorder="little") # arbitrary await self.send(b"\xaa\x01\xd4\x97" + new_position + b"\xc3\xf5\x28\x00\xd7\x1a\x00\x00\x49") await self.send(b"\xaa\x01\x0e\x0f") await self.send(b"\xaa\x01\x0e\x0f") @@ -252,7 +287,31 @@ async def setup(self): await self.send(b"\xaa\x01\x0e\x0f") - self._bucket_1_position = (await self.get_position()) + self.calibration_offset + @property + def bucket_1_remainder(self) -> int: + if self._bucket_1_remainder is None: + raise RuntimeError( + "Bucket 1 position not set. Please set it using `set_bucket_1_position_to_current` method." + ) + return self._bucket_1_remainder + + async def set_bucket_1_position_to_current(self) -> None: + """Set the current position as bucket 1 position and save calibration.""" + current_position = await self.get_position() + device_id = await self.io.get_serial() + remainder = await self.get_home_position() - current_position + self._bucket_1_remainder = current_position % FULL_ROTATION + _save_vspin_calibrations(device_id, remainder) + + async def get_bucket_1_position(self) -> int: + """Get the bucket 1 position based on calibration.""" + if self._bucket_1_remainder is None: + raise RuntimeError( + "Bucket 1 position not set. Please set it using `set_bucket_1_position_to_current` method." + ) + home_position = await self.get_home_position() + bucket_1_position = (home_position - self.bucket_1_remainder) % FULL_ROTATION + return bucket_1_position async def stop(self): await self.send(b"\xaa\x02\x0e\x10") @@ -284,6 +343,10 @@ async def get_position(self): resp = await self.get_status() return int.from_bytes(resp[1:5], byteorder="little") + async def get_home_position(self) -> int: + resp = await self.get_status() + return int.from_bytes(resp[9:13], byteorder="little") + # Centrifuge communication: read_resp, send, send_payloads async def read_resp(self, timeout=20) -> bytes: @@ -373,11 +436,10 @@ async def unlock_bucket(self): await self.send(b"\xaa\x02\x0e\x10") async def go_to_bucket1(self): - await self.go_to_position(self._bucket_1_position) + await self.go_to_position(await self.get_bucket_1_position()) async def go_to_bucket2(self): - half_rotation = 4000 - await self.go_to_position(self._bucket_1_position + half_rotation) + await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) async def rotate_distance(self, distance): current_position = await self.get_position() diff --git a/pylabrobot/io/ftdi.py b/pylabrobot/io/ftdi.py index 5ff9431779c..eb56bc82502 100644 --- a/pylabrobot/io/ftdi.py +++ b/pylabrobot/io/ftdi.py @@ -133,6 +133,12 @@ async def poll_modem_status(self) -> int: ) return stat.value + async def get_serial(self) -> str: + serial = self._dev.driver.list_devices()[self._dev.device_index][2] # type: ignore + logger.log(LOG_LEVEL_IO, "[%s] get_serial %s", self._device_id, serial) + capturer.record(FTDICommand(device_id=self._device_id, action="get_serial", data=str(serial))) + return serial # type: ignore + async def stop(self): self.dev.close() if self._executor is not None: @@ -284,6 +290,18 @@ async def poll_modem_status(self) -> int: ) return int(next_command.data) + async def get_serial(self) -> str: + next_command = FTDICommand(**self.cr.next_command()) + if not ( + next_command.module == "ftdi" + and next_command.device_id == self._device_id + and next_command.action == "get_serial" + ): + raise ValidationError( + f"Next line is {next_command}, expected FTDI get_serial {self._device_id}" + ) + return next_command.data + async def write(self, data: bytes): next_command = FTDICommand(**self.cr.next_command()) if not (