Skip to content

Commit 68d1027

Browse files
[rotations] add ResourceHolderMixin to fix placement in a number of resources (#239)
Co-authored-by: Rick Wierenga <[email protected]>
1 parent dc97d25 commit 68d1027

File tree

11 files changed

+143
-51
lines changed

11 files changed

+143
-51
lines changed

Diff for: pylabrobot/liquid_handling/backends/hamilton/STAR.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from pylabrobot.resources.hamilton.hamilton_decks import STAR_SIZE_X, STARLET_SIZE_X
4141
from pylabrobot.resources.liquid import Liquid
4242
from pylabrobot.resources.ml_star import HamiltonTip, TipDropMethod, TipPickupMethod, TipSize
43-
from pylabrobot.resources.utils import get_child_location
43+
from pylabrobot.resources.resource_holder import get_child_location
4444
from pylabrobot.utils.linalg import matrix_vector_multiply_3x3
4545

4646
T = TypeVar("T")

Diff for: pylabrobot/liquid_handling/liquid_handler_tests.py

+42-1
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
""" Tests for LiquidHandler """
22
# pylint: disable=missing-class-docstring
33

4+
import itertools
45
import pytest
56
import tempfile
6-
from typing import Any, Dict, List, Optional, cast
7+
from typing import Any, Dict, List, Optional, Union, cast
78
import unittest
89
import unittest.mock
910

1011
from pylabrobot.liquid_handling.strictness import Strictness, set_strictness
1112
from pylabrobot.resources import no_tip_tracking, set_tip_tracking, Liquid
13+
from pylabrobot.resources.carrier import PlateCarrierSite
1214
from pylabrobot.resources.errors import HasTipError, NoTipError, CrossContaminationError
1315
from pylabrobot.resources.volume_tracker import set_volume_tracking, set_cross_contamination_tracking
16+
from pylabrobot.resources.well import Well
17+
from pylabrobot.resources.utils import create_ordered_items_2d
1418

1519
from . import backends
1620
from .liquid_handler import LiquidHandler, OperationCallback
@@ -30,6 +34,7 @@
3034
from pylabrobot.resources.hamilton import STARLetDeck
3135
from pylabrobot.resources.ml_star import STF_L, HTF_L
3236
from .standard import (
37+
GripDirection,
3338
Pickup,
3439
Drop,
3540
DropTipRack,
@@ -249,6 +254,42 @@ async def test_move_plate_onto_resource_stack_with_plate(self):
249254
self.assertEqual(plate2.location.z, 15)
250255
self.assertEqual(stack.get_absolute_size_z(), 30)
251256

257+
async def test_move_plate_rotation(self):
258+
rotations = [0, 90, 270, 360]
259+
grip_directions = [
260+
(GripDirection.LEFT, GripDirection.RIGHT),
261+
(GripDirection.FRONT, GripDirection.BACK),
262+
]
263+
sites: List[Union[ResourceStack, PlateCarrierSite]] = [
264+
ResourceStack(name="stack", direction="z"),
265+
PlateCarrierSite(name="site", size_x=100, size_y=100, size_z=15, pedestal_size_z=1)
266+
]
267+
268+
test_cases = itertools.product(sites, rotations, grip_directions)
269+
270+
for site, rotation, (get_direction, put_direction) in test_cases:
271+
with self.subTest(stack_type=site.__class__.__name__, rotation=rotation,
272+
get_direction=get_direction, put_direction=put_direction):
273+
self.deck.assign_child_resource(site, location=Coordinate(100, 100, 0))
274+
275+
plate = Plate("plate", size_x=100, size_y=100, size_z=15,
276+
ordered_items=create_ordered_items_2d(
277+
Well, num_items_x=1, num_items_y=1, dx=0, dy=0, dz=0,
278+
item_dx=10, item_dy=10, size_x=10, size_y=10, size_z=10))
279+
plate.rotate(z=rotation)
280+
site.assign_child_resource(plate)
281+
original_center = plate.get_absolute_location(x="c", y="c", z="c")
282+
await self.lh.move_plate(plate, site, get_direction=get_direction,
283+
put_direction=put_direction)
284+
new_center = plate.get_absolute_location(x="c", y="c", z="c")
285+
286+
self.assertEqual(new_center, original_center,
287+
f"Center mismatch for {site.__class__.__name__}, rotation {rotation}, "
288+
f"get_direction {get_direction}, "
289+
f"put_direction {put_direction}")
290+
plate.unassign()
291+
self.deck.unassign_child_resource(site)
292+
252293
def test_serialize(self):
253294
serialized = self.lh.serialize()
254295
deserialized = LiquidHandler.deserialize(serialized)

Diff for: pylabrobot/plate_reading/plate_reader.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
from pylabrobot.machines.machine import Machine, need_setup_finished
44
from pylabrobot.resources import Coordinate, Plate, Resource
55
from pylabrobot.plate_reading.backend import PlateReaderBackend
6-
from pylabrobot.resources.utils import get_child_location
6+
from pylabrobot.resources.resource_holder import ResourceHolderMixin
77

88

99
class NoPlateError(Exception):
1010
pass
1111

1212

13-
class PlateReader(Machine):
13+
class PlateReader(ResourceHolderMixin, Machine):
1414
""" The front end for plate readers. Plate readers are devices that can read luminescence,
1515
absorbance, or fluorescence from a plate.
1616
@@ -42,12 +42,12 @@ def __init__(
4242

4343
def assign_child_resource(self, resource: Resource, location: Optional[Coordinate]=None,
4444
reassign: bool = True):
45-
location = location or get_child_location(resource)
4645
if len(self.children) >= 1:
4746
raise ValueError("There already is a plate in the plate reader.")
4847
if not isinstance(resource, Plate):
4948
raise ValueError("The resource must be a Plate.")
50-
super().assign_child_resource(resource, location=location)
49+
50+
super().assign_child_resource(resource, location=location, reassign=reassign)
5151

5252
def get_plate(self) -> Plate:
5353
if len(self.children) == 0:
@@ -87,7 +87,7 @@ async def read_fluorescence(
8787
emission_wavelength: int,
8888
focal_height: float
8989
) -> List[List[float]]:
90-
"""
90+
"""
9191
9292
Args:
9393
excitation_wavelength: The excitation wavelength to read the fluorescence at, in nanometers.

Diff for: pylabrobot/resources/carrier.py

+9-7
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
from typing import Generic, List, Optional, Type, TypeVar, Union
55

6-
from pylabrobot.resources.utils import get_child_location
6+
from pylabrobot.resources.resource_holder import ResourceHolderMixin
77

88
from .coordinate import Coordinate
99
from .plate import Plate
@@ -14,7 +14,7 @@
1414
logger = logging.getLogger("pylabrobot")
1515

1616

17-
class CarrierSite(Resource):
17+
class CarrierSite(ResourceHolderMixin, Resource):
1818
""" A single site within a carrier. """
1919

2020
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
@@ -30,7 +30,6 @@ def assign_child_resource(
3030
reassign: bool = True
3131
):
3232
self.resource = resource
33-
location = location or get_child_location(resource)
3433
return super().assign_child_resource(resource, location, reassign)
3534

3635
def unassign_child_resource(self, resource):
@@ -207,7 +206,6 @@ def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
207206

208207
def assign_child_resource(self, resource: Resource, location: Optional[Coordinate] = None,
209208
reassign: bool = True):
210-
location = location or self._get_child_location(resource)
211209
if isinstance(resource, ResourceStack):
212210
if not resource.direction == "z":
213211
raise ValueError("ResourceStack assigned to PlateCarrierSite must have direction 'z'")
@@ -219,7 +217,7 @@ def assign_child_resource(self, resource: Resource, location: Optional[Coordinat
219217
f"resources, not {type(resource)}")
220218
return super().assign_child_resource(resource, location, reassign)
221219

222-
def _get_child_location(self, resource: Resource) -> Coordinate:
220+
def _get_sinking_depth(self, resource: Resource) -> Coordinate:
223221
def get_plate_sinking_depth(plate: Plate):
224222
# Sanity check for equal well clearances / dz
225223
well_dz_set = {round(well.location.z, 2) for well in plate.get_all_children()
@@ -238,10 +236,14 @@ def get_plate_sinking_depth(plate: Plate):
238236
first_child = resource.children[0]
239237
if isinstance(first_child, Plate):
240238
z_sinking_depth = get_plate_sinking_depth(first_child)
239+
240+
# TODO #246 - _get_sinking_depth should not handle callbacks
241241
resource.register_did_assign_resource_callback(self._update_resource_stack_location)
242242
self.register_did_unassign_resource_callback(self._deregister_resource_stack_callback)
243+
return -Coordinate(z=z_sinking_depth)
243244

244-
return get_child_location(resource) - Coordinate(z=z_sinking_depth)
245+
def get_default_child_location(self, resource: Resource) -> Coordinate:
246+
return super().get_default_child_location(resource) + self._get_sinking_depth(resource)
245247

246248
def _update_resource_stack_location(self, resource: Resource):
247249
""" Callback called when the lowest resource on a ResourceStack changes. Since the location of
@@ -254,7 +256,7 @@ def _update_resource_stack_location(self, resource: Resource):
254256
resource_stack = resource.parent
255257
assert isinstance(resource_stack, ResourceStack)
256258
if resource_stack.children[0] == resource:
257-
resource_stack.location = self._get_child_location(resource)
259+
resource_stack.location = self.get_default_child_location(resource)
258260

259261
def _deregister_resource_stack_callback(self, resource: Resource):
260262
""" Callback called when a ResourceStack (or child) is unassigned from this PlateCarrierSite."""

Diff for: pylabrobot/resources/plate.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from typing import Dict, List, Optional, Sequence, Tuple, Union, cast, Literal
66

7+
from pylabrobot.resources.resource_holder import ResourceHolderMixin
8+
79

810
from .liquid import Liquid
911
from .itemized_resource import ItemizedResource
@@ -44,7 +46,7 @@ def serialize(self) -> dict:
4446
return {**super().serialize(), "nesting_z_height": self.nesting_z_height}
4547

4648

47-
class Plate(ItemizedResource[Well]):
49+
class Plate(ResourceHolderMixin, ItemizedResource[Well]):
4850
""" Base class for Plate resources. """
4951

5052
def __init__(

Diff for: pylabrobot/resources/resource_holder.py

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from typing import Optional
2+
from pylabrobot.resources.coordinate import Coordinate
3+
from pylabrobot.resources.resource import Resource
4+
5+
def get_child_location(resource: Resource) -> Coordinate:
6+
"""
7+
If a resource is rotated, its rotated around its local origin. This does not always
8+
match up with the parent resource's origin. This function calculates the difference
9+
between the two origins and calculates the location of the resource correctly.
10+
"""
11+
if not resource.rotation.y == resource.rotation.x == 0:
12+
raise ValueError("Resource rotation must be 0 around the x and y axes")
13+
if not resource.rotation.z % 90 == 0:
14+
raise ValueError("Resource rotation must be a multiple of 90 degrees on the z axis")
15+
location = {
16+
0.0: Coordinate(x=0, y=0, z=0),
17+
90.0: Coordinate(x=resource.get_size_y(), y=0, z=0),
18+
180.0: Coordinate(x=resource.get_size_y(), y=resource.get_size_x(), z=0),
19+
270.0: Coordinate(x=0, y=resource.get_size_x(), z=0),
20+
}[resource.rotation.z % 360]
21+
return location
22+
23+
class ResourceHolderMixin:
24+
"""
25+
A mixin class for resources that can hold other resources, like a plate or a lid.
26+
27+
This applies a linear transformation after the rotation to correctly place the child resource.
28+
"""
29+
30+
def get_default_child_location(self, resource: Resource) -> Coordinate:
31+
return get_child_location(resource)
32+
33+
def assign_child_resource(
34+
self,
35+
resource: Resource,
36+
location: Optional[Coordinate] = None,
37+
reassign: bool = True
38+
):
39+
location = location or self.get_default_child_location(resource)
40+
# mypy doesn't play well with the Mixin pattern
41+
return super().assign_child_resource(resource, location, reassign) # type: ignore

Diff for: pylabrobot/resources/resource_stack.py

+34-12
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import logging
12
from typing import List, Optional
23

4+
from pylabrobot.resources.resource_holder import ResourceHolderMixin, get_child_location
35
from pylabrobot.resources.resource import Resource
46
from pylabrobot.resources.coordinate import Coordinate
57
from pylabrobot.resources.plate import Lid, Plate
68

9+
logger = logging.getLogger("pylabrobot")
710

8-
class ResourceStack(Resource):
11+
class ResourceStack(ResourceHolderMixin, Resource):
912
""" ResourceStack represent a group of resources that are stacked together and act as a single
1013
unit. Stacks can grow be configured to be able to grow in x, y, or z direction. Stacks growing
1114
in the x direction are from left to right. Stacks growing in the y direction are from front to
@@ -95,26 +98,45 @@ def get_actual_resource_height(resource: Resource) -> float:
9598
return max(get_actual_resource_height(child) for child in self.children)
9699
return sum(get_actual_resource_height(child) for child in self.children)
97100

98-
def assign_child_resource(self, resource: Resource, location: Optional[Coordinate] = None,
99-
reassign: bool = False):
101+
102+
def get_resource_stack_edge(self) -> Coordinate:
100103
if self.direction == "x":
101104
resource_location = Coordinate(self.get_size_x(), 0, 0)
102105
elif self.direction == "y":
103106
resource_location = Coordinate(0, self.get_size_y(), 0)
104107
elif self.direction == "z":
105108
resource_location = Coordinate(0, 0, self.get_size_z())
106-
107-
# special handling for putting a lid on a plate
108-
if len(self.children) > 0:
109-
top_item = self.get_top_item()
110-
if isinstance(resource, Lid) and isinstance(top_item, Plate):
111-
resource_location.z -= resource.nesting_z_height
112-
top_item.assign_child_resource(resource, location=resource_location)
113-
return
114109
else:
115110
raise ValueError("self.direction must be one of 'x', 'y', or 'z'")
116111

117-
super().assign_child_resource(resource, location=resource_location)
112+
return resource_location
113+
114+
def get_default_child_location(self, resource: Resource) -> Coordinate:
115+
return super().get_default_child_location(resource) + self.get_resource_stack_edge()
116+
117+
def assign_child_resource(self, resource: Resource, location: Optional[Coordinate] = None,
118+
reassign: bool = False):
119+
120+
# special handling for putting a lid on a plate
121+
# TODO #247 - Remove special case assignment of a lid to a resource stack
122+
if len(self.children) > 0:
123+
top_item = self.get_top_item()
124+
if isinstance(resource, Lid) and isinstance(top_item, Plate):
125+
logger.warning("Assigning a lid to a resource stack is deprecated and will be "
126+
"removed in a future version. Assign the lid to the plate directly instead.")
127+
resource_location = self.get_resource_stack_edge()
128+
resource_location.z -= resource.nesting_z_height
129+
top_item.assign_child_resource(
130+
resource,
131+
location=get_child_location(resource) + resource_location
132+
)
133+
return
134+
135+
super().assign_child_resource(
136+
resource,
137+
location=location,
138+
reassign=reassign
139+
)
118140

119141
def unassign_child_resource(self, resource: Resource):
120142
if self.direction == "z" and resource != self.children[-1]: # no floating resources

Diff for: pylabrobot/resources/utils.py

-18
Original file line numberDiff line numberDiff line change
@@ -199,21 +199,3 @@ def query(
199199
z=z,
200200
))
201201
return matched
202-
203-
def get_child_location(resource: Resource) -> Coordinate:
204-
"""
205-
If a resource is rotated, its rotated around its local origin. This does not always
206-
match up with the parent resource's origin. This function calculates the difference
207-
between the two origins and calculates the location of the resource correctly.
208-
"""
209-
if not resource.rotation.y == resource.rotation.x == 0:
210-
raise ValueError("Resource rotation must be 0 around the x and y axes")
211-
if not resource.rotation.z % 90 == 0:
212-
raise ValueError("Resource rotation must be a multiple of 90 degrees on the z axis")
213-
location = {
214-
0.0: Coordinate(x=0, y=0, z=0),
215-
90.0: Coordinate(x=resource.get_size_y(), y=0, z=0),
216-
180.0: Coordinate(x=resource.get_size_y(), y=resource.get_size_x(), z=0),
217-
270.0: Coordinate(x=0, y=resource.get_size_x(), z=0),
218-
}[resource.rotation.z % 360]
219-
return location

Diff for: pylabrobot/shaking/shaker.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
from typing import Optional
33

44
from pylabrobot.machines.machine import Machine
5+
from pylabrobot.resources.resource_holder import ResourceHolderMixin
56

67
from .backend import ShakerBackend
78

89

9-
class Shaker(Machine):
10+
class Shaker(ResourceHolderMixin, Machine):
1011
""" A shaker machine """
1112

1213
def __init__(

Diff for: pylabrobot/temperature_controlling/temperature_controller.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
from typing import Optional
44

55
from pylabrobot.machines.machine import Machine
6-
6+
from pylabrobot.resources.resource_holder import ResourceHolderMixin
77
from .backend import TemperatureControllerBackend
88

99

10-
class TemperatureController(Machine):
10+
class TemperatureController(ResourceHolderMixin, Machine):
1111
""" Temperature controller, for heating or for cooling. """
1212

1313
def __init__(
@@ -20,7 +20,8 @@ def __init__(
2020
category: str = "temperature_controller",
2121
model: Optional[str] = None
2222
):
23-
super().__init__(name, size_x, size_y, size_z, backend, category, model)
23+
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z,
24+
backend=backend, category=category, model=model)
2425
self.backend: TemperatureControllerBackend = backend # fix type
2526
self.target_temperature: Optional[float] = None
2627

Diff for: pylabrobot/tilting/tilter.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
from pylabrobot.machines import Machine
55
from pylabrobot.resources import Coordinate, Plate
66
from pylabrobot.resources.well import CrossSectionType, Well
7-
7+
from pylabrobot.resources.resource_holder import ResourceHolderMixin
88
from .tilter_backend import TilterBackend
99

1010

11-
class Tilter(Machine):
11+
class Tilter(ResourceHolderMixin, Machine):
1212
""" Resources that tilt plates. """
1313

1414
def __init__(

0 commit comments

Comments
 (0)