diff --git a/docs/user_guide/hamilton-star/foil.ipynb b/docs/user_guide/hamilton-star/foil.ipynb new file mode 100644 index 0000000000..7f336eb2b8 --- /dev/null +++ b/docs/user_guide/hamilton-star/foil.ipynb @@ -0,0 +1,192 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Foil\n", + "\n", + "The :class:`~pylabrobot.liquid_handling.backends.hamilton.STAR.STAR` backend includes special utilities for working with foil-sealed plates, specifically:\n", + "\n", + "1. a function to pierce foil before aspirating from the plate, and\n", + "2. a function to keep the plate down while moving the channels up to avoid lifting the plate." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example setup\n", + "\n", + "```{note}\n", + "While this example uses high volume tips, it _might_ be possible to use other tip types to pierce the foil. However, 50uL tips are very soft and probably can't be used.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.liquid_handling import LiquidHandler, STAR\n", + "from pylabrobot.resources import STARLetDeck\n", + "from pylabrobot.resources import (\n", + " TIP_CAR_480_A00,\n", + " PLT_CAR_L5AC_A00,\n", + " HT,\n", + " AGenBio_4_wellplate_Vb\n", + ")\n", + "\n", + "star = STAR()\n", + "lh = LiquidHandler(backend=star, deck=STARLetDeck())\n", + "await lh.setup()\n", + "\n", + "# assign a tip rack\n", + "tip_carrier = TIP_CAR_480_A00(name=\"tip_carrier\")\n", + "tip_carrier[1] = tip_rack = HT(name=\"tip_rack\")\n", + "lh.deck.assign_child_resource(tip_carrier, rails=1)\n", + "\n", + "# assign a plate\n", + "plt_carrier = PLT_CAR_L5AC_A00(name=\"plt_carrier\")\n", + "plt_carrier[0] = plate = AGenBio_4_wellplate_Vb(name=\"plate\")\n", + "lh.deck.assign_child_resource(plt_carrier, rails=10)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Breaking the foil before using a plate\n", + "\n", + "It is important to break the foil before aspirating because tiny foil pieces can stuck in the tip, drastically changing the liquid handling characteristics.\n", + "\n", + "In this example, we will use an 8 channel workcell and use the inner 6 channels for breaking the foil and then aspirating. We will use the outer 2 channels to keep the plate down while the inner channels are moving up." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "well = plate.get_well(\"A1\")\n", + "await lh.pick_up_tips(tip_rack[\"A1:H1\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "aspiration_channels = [1, 2, 3, 4, 5, 6]\n", + "hold_down_channels = [0, 7]\n", + "await star.pierce_foil(\n", + " wells=[well],\n", + " piercing_channels=aspiration_channels,\n", + " hold_down_channels=hold_down_channels,\n", + " move_inwards=4,\n", + " one_by_one=False,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "await lh.return_tips()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![gif of piercing foil](./img/pierce_foil.gif)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Holding the plate down\n", + "\n", + "Holding the plate down while moving channels up after aspiration consists of two parts:\n", + "1. Making the channels stay down after a liquid handling operation has finished. By default, STAR will move channels up to traversal height.\n", + "2. Putting two channels on the edges of the plate to hold it down, while moving the other channels up." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "await lh.pick_up_tips(tip_rack[\"A2:H2\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "num_channels = len(aspiration_channels)\n", + "await lh.aspirate(\n", + " [well]*num_channels, vols=[100]*num_channels, use_channels=aspiration_channels,\n", + "\n", + " # aspiration parameters (backend_kwargs)\n", + " min_z_endpos=well.get_absolute_location(z=\"cavity_bottom\").z, # z end position: where channels go after aspiration\n", + " surface_following_distance=0, # no moving in z dimension during aspiration\n", + " pull_out_distance_transport_air=[0] * num_channels # no moving up to aspirate transport air after aspiration\n", + ")\n", + "\n", + "await star.step_off_foil(\n", + " well,\n", + " front_channel=7,\n", + " back_channel=0,\n", + " move_inwards=5,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "await lh.return_tips()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![gif of holding down foil](./img/step_off_foil.gif)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.15" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/user_guide/hamilton-star/hamilton-star.rst b/docs/user_guide/hamilton-star/hamilton-star.rst index a35686a618..ad2e6f6001 100644 --- a/docs/user_guide/hamilton-star/hamilton-star.rst +++ b/docs/user_guide/hamilton-star/hamilton-star.rst @@ -9,3 +9,4 @@ Tools for working with Hamilton-STAR specific functions. iswap-module star_lld z-probing + foil diff --git a/docs/user_guide/hamilton-star/img/pierce_foil.gif b/docs/user_guide/hamilton-star/img/pierce_foil.gif new file mode 100644 index 0000000000..6eddb08579 Binary files /dev/null and b/docs/user_guide/hamilton-star/img/pierce_foil.gif differ diff --git a/docs/user_guide/hamilton-star/img/step_off_foil.gif b/docs/user_guide/hamilton-star/img/step_off_foil.gif new file mode 100644 index 0000000000..b11a824fbf Binary files /dev/null and b/docs/user_guide/hamilton-star/img/step_off_foil.gif differ diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR.py b/pylabrobot/liquid_handling/backends/hamilton/STAR.py index 6191353e48..86a218b048 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR.py @@ -7736,6 +7736,11 @@ async def pierce_foil( x: float ys: List[float] z: float + + # if only one well is give, but in a list, convert to Well so we fall into single-well logic. + if isinstance(wells, list) and len(wells) == 1: + wells = wells[0] + if isinstance(wells, Well): well = wells x, y, z = well.get_absolute_location("c", "c", "cavity_bottom") @@ -7753,9 +7758,10 @@ async def pierce_foil( assert ( len(set(w.get_absolute_location().x for w in wells)) == 1 ), "Wells must be on the same column" - x = wells[0].get_absolute_location().x - ys = [well.get_absolute_location().y for well in wells] - z = wells[0].get_absolute_location(z="cavity_bottom").z + absolute_center = wells[0].get_absolute_location("c", "c", "cavity_bottom") + x = absolute_center.x + ys = [well.get_absolute_location(y="c").y for well in wells] + z = absolute_center.z await self.move_channel_x(0, x=x) @@ -7773,7 +7779,7 @@ async def pierce_foil( ) await self.step_off_foil( - well, + [wells] if isinstance(wells, Well) else wells, back_channel=hold_down_channels[0], front_channel=hold_down_channels[1], move_inwards=move_inwards, @@ -7781,7 +7787,7 @@ async def pierce_foil( async def step_off_foil( self, - well: Well, + wells: Union[Well, List[Well]], front_channel: int, back_channel: int, move_inwards: float = 2, @@ -7804,10 +7810,11 @@ async def step_off_foil( min_z_endpos=well.get_absolute_location(z="cavity_bottom").z, surface_following_distance=0, pull_out_distance_transport_air=[0] * 4) - await step_off_foil(lh.backend, well, front_channel=11, back_channel=6, move_inwards = 3) + await step_off_foil(lh.backend, [well], front_channel=11, back_channel=6, move_inwards=3) Args: - well: Well in the plate to hold down. (x-coordinate of channels will be at center of well). + wells: Wells in the plate to hold down. (x-coordinate of channels will be at center of wells). + Must be sorted from back to front. front_channel: The channel to place on the front of the plate. back_channel: The channel to place on the back of the plate. move_inwards: mm to move inwards (backward on the front channel; frontward on the back). @@ -7819,22 +7826,30 @@ async def step_off_foil( "front_channel should be in front of back_channel. " "Channels are 0-indexed from the back." ) - # Get the absolute locations for center front top and center back top - orientation = well.get_absolute_rotation().z % 90 - if orientation == 0: - back_location = well.get_absolute_location("c", "b", "t") - front_location = well.get_absolute_location("c", "f", "t") - elif orientation == 90: - back_location = well.get_absolute_location("r", "c", "t") - front_location = well.get_absolute_location("l", "c", "t") - elif orientation == 180: - back_location = well.get_absolute_location("c", "f", "b") - front_location = well.get_absolute_location("c", "b", "b") - elif orientation == 270: - back_location = well.get_absolute_location("l", "c", "b") - front_location = well.get_absolute_location("r", "c", "b") + if isinstance(wells, Well): + wells = [wells] + + plates = set(well.parent for well in wells) + assert len(plates) == 1, "All wells must be in the same plate" + plate = plates.pop() + assert plate is not None + + z_location = plate.get_absolute_location(z="top").z + + if plate.get_absolute_rotation().z % 360 == 0: + back_location = plate.get_absolute_location(y="b") + front_location = plate.get_absolute_location(y="f") + elif plate.get_absolute_rotation().z % 360 == 90: + back_location = plate.get_absolute_location(x="r") + front_location = plate.get_absolute_location(x="l") + elif plate.get_absolute_rotation().z % 360 == 180: + back_location = plate.get_absolute_location(y="f") + front_location = plate.get_absolute_location(y="b") + elif plate.get_absolute_rotation().z % 360 == 270: + back_location = plate.get_absolute_location(x="l") + front_location = plate.get_absolute_location(x="r") else: - raise ValueError("Rotation of well must be a multiple of 90 degrees") + raise ValueError("Plate rotation must be a multiple of 90 degrees") try: # Then move all channels in the y-space simultaneously. @@ -7845,17 +7860,15 @@ async def step_off_foil( } ) - await self.move_channel_z(front_channel, front_location.z) - await self.move_channel_z(back_channel, back_location.z) + await self.move_channel_z(front_channel, z_location) + await self.move_channel_z(back_channel, z_location) finally: # Move channels that are lower than the `front_channel` and `back_channel` to # the just above the foil, in case the foil pops up. zs = await self.get_channels_z_positions() - indices = [channel_idx for channel_idx, z in zs.items() if z < front_location.z] + indices = [channel_idx for channel_idx, z in zs.items() if z < z_location] idx = { - idx: front_location.z + move_height - for idx in indices - if idx not in (front_channel, back_channel) + idx: z_location + move_height for idx in indices if idx not in (front_channel, back_channel) } await self.position_channels_in_z_direction(idx)