Skip to content

Commit 6d3566d

Browse files
authored
Merge pull request #334 from PyLabRobot/loader-resource-mgmt
2 parents 81b260d + b1a3876 commit 6d3566d

File tree

4 files changed

+194
-7
lines changed

4 files changed

+194
-7
lines changed

pylabrobot/centrifuge/__init__.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
from .access2 import Access2
22
from .centrifuge import Centrifuge, Loader
3-
from .standard import LoaderNoPlateError
3+
from .standard import (
4+
BucketHasPlateError,
5+
BucketNoPlateError,
6+
CentrifugeDoorError,
7+
LoaderNoPlateError,
8+
NotAtBucketError,
9+
)
410
from .vspin import Access2Backend, VSpin

pylabrobot/centrifuge/centrifuge.py

+52-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1+
from typing import Optional
2+
13
from pylabrobot.centrifuge.backend import CentrifugeBackend, LoaderBackend
4+
from pylabrobot.centrifuge.standard import (
5+
BucketHasPlateError,
6+
BucketNoPlateError,
7+
CentrifugeDoorError,
8+
LoaderNoPlateError,
9+
NotAtBucketError,
10+
)
211
from pylabrobot.machines.machine import Machine
312
from pylabrobot.resources.coordinate import Coordinate
413
from pylabrobot.resources.resource_holder import ResourceHolder
@@ -11,6 +20,13 @@ def __init__(self, backend: CentrifugeBackend) -> None:
1120
super().__init__(backend=backend)
1221
self.backend: CentrifugeBackend = backend # fix type
1322
self._door_open = False
23+
self._at_bucket: Optional[ResourceHolder] = None
24+
self.bucket1 = ResourceHolder(
25+
name="bucket1", size_x=127.76, size_y=85.48, size_z=0, child_location=Coordinate.zero()
26+
)
27+
self.bucket2 = ResourceHolder(
28+
name="bucket2", size_x=127.76, size_y=85.48, size_z=0, child_location=Coordinate.zero()
29+
)
1430

1531
async def open_door(self) -> None:
1632
await self.backend.open_door()
@@ -38,23 +54,28 @@ async def lock_bucket(self) -> None:
3854

3955
async def go_to_bucket1(self) -> None:
4056
await self.backend.go_to_bucket1()
57+
self._at_bucket = self.bucket1
4158

4259
async def go_to_bucket2(self) -> None:
4360
await self.backend.go_to_bucket2()
61+
self._at_bucket = self.bucket2
4462

4563
async def rotate_distance(self, distance) -> None:
4664
await self.backend.rotate_distance(distance=distance)
65+
self._at_bucket = None
4766

4867
async def start_spin_cycle(self, g: float, duration: float, acceleration: float) -> None:
4968
await self.backend.start_spin_cycle(
5069
g=g,
5170
duration=duration,
5271
acceleration=acceleration,
5372
)
73+
self._at_bucket = None
5474

55-
56-
class CentrifugeDoorError(Exception):
57-
pass
75+
@property
76+
def at_bucket(self) -> Optional[ResourceHolder]:
77+
"""None if not at a bucket or unknown, otherwise the resource representing the bucket."""
78+
return self._at_bucket
5879

5980

6081
class Loader(Machine, ResourceHolder):
@@ -87,19 +108,44 @@ def __init__(
87108
model=model,
88109
)
89110
self.backend: LoaderBackend = backend # fix type
90-
self.centrifuge: Centrifuge = centrifuge
111+
self.centrifuge = centrifuge
91112

92113
async def load(self) -> None:
93114
if not self.centrifuge.door_open:
94115
raise CentrifugeDoorError("Centrifuge door must be open to load a plate.")
116+
117+
if self.centrifuge.at_bucket is None:
118+
raise NotAtBucketError(
119+
"Centrifuge must be at a bucket to load a plate, but current position is unknown or not at "
120+
"a bucket. Use centrifuge.go_to_bucket{1,2}() to move to a bucket."
121+
)
122+
123+
if self.resource is None:
124+
raise LoaderNoPlateError("Loader must have a plate to load.")
125+
126+
if self.centrifuge.at_bucket.resource is not None:
127+
raise BucketHasPlateError("Bucket must be empty to load a plate.")
128+
95129
await self.backend.load()
96-
# TODO: assign plate to centrifuge bucket, at no location
130+
131+
self.centrifuge.at_bucket.assign_child_resource(self.resource, location=Coordinate.zero())
97132

98133
async def unload(self) -> None: # DOOR arg?
99134
if not self.centrifuge.door_open:
100135
raise CentrifugeDoorError("Centrifuge door must be open to unload a plate.")
136+
137+
if self.centrifuge.at_bucket is None:
138+
raise NotAtBucketError(
139+
"Centrifuge must be at a bucket to unload a plate, but current position is unknown or not "
140+
"at a bucket. Use centrifuge.go_to_bucket{1,2}() to move to a bucket."
141+
)
142+
143+
if self.centrifuge.at_bucket.resource is None:
144+
raise BucketNoPlateError("Bucket must have a plate to unload.")
145+
101146
await self.backend.unload()
102-
# TODO: assign plate from centrifuge bucket to self
147+
148+
self.assign_child_resource(self.centrifuge.at_bucket.resource)
103149

104150
def serialize(self) -> dict:
105151
return {
+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import unittest
2+
3+
from pylabrobot.centrifuge import (
4+
BucketHasPlateError,
5+
BucketNoPlateError,
6+
Centrifuge,
7+
CentrifugeDoorError,
8+
Loader,
9+
LoaderNoPlateError,
10+
NotAtBucketError,
11+
)
12+
from pylabrobot.centrifuge.backend import CentrifugeBackend, LoaderBackend
13+
from pylabrobot.resources import Coordinate, Cor_96_wellplate_360ul_Fb
14+
15+
16+
class CentrifugeLoaderResourceModelTests(unittest.IsolatedAsyncioTestCase):
17+
async def asyncSetUp(self):
18+
self.mock_centrifuge_backend = unittest.mock.MagicMock(spec=CentrifugeBackend)
19+
self.mock_loader_backend = unittest.mock.MagicMock(spec=LoaderBackend)
20+
self.centrifuge = Centrifuge(backend=self.mock_centrifuge_backend)
21+
self.loader = Loader(
22+
backend=self.mock_loader_backend,
23+
centrifuge=self.centrifuge,
24+
name="loader",
25+
size_x=1,
26+
size_y=1,
27+
size_z=1,
28+
child_location=Coordinate.zero(),
29+
)
30+
self.plate = Cor_96_wellplate_360ul_Fb(name="plate")
31+
return await super().asyncSetUp()
32+
33+
async def test_go_to_bucket(self):
34+
self.assertIsNone(self.centrifuge.at_bucket)
35+
await self.centrifuge.go_to_bucket1()
36+
self.assertEqual(self.centrifuge.at_bucket, self.centrifuge.bucket1)
37+
await self.centrifuge.go_to_bucket2()
38+
self.assertEqual(self.centrifuge.at_bucket, self.centrifuge.bucket2)
39+
40+
async def test_load(self):
41+
await self.centrifuge.go_to_bucket1()
42+
await self.centrifuge.open_door()
43+
assert self.centrifuge._door_open
44+
assert self.centrifuge.door_open
45+
self.loader.assign_child_resource(self.plate)
46+
await self.loader.load()
47+
self.mock_loader_backend.load.assert_awaited_once()
48+
assert self.centrifuge.at_bucket is not None
49+
self.assertEqual(self.centrifuge.at_bucket.children[0], self.plate)
50+
self.assertEqual(self.loader.children, [])
51+
52+
async def test_load_locked_door(self):
53+
self.loader.assign_child_resource(self.plate)
54+
with self.assertRaises(CentrifugeDoorError):
55+
await self.loader.load()
56+
self.mock_loader_backend.load.assert_not_awaited()
57+
58+
async def test_load_no_plate(self):
59+
await self.centrifuge.go_to_bucket1()
60+
await self.centrifuge.open_door()
61+
with self.assertRaises(LoaderNoPlateError):
62+
await self.loader.load()
63+
self.mock_loader_backend.load.assert_not_awaited()
64+
65+
async def test_load_bucket_has_plate(self):
66+
await self.centrifuge.go_to_bucket1()
67+
await self.centrifuge.open_door()
68+
assert self.centrifuge.at_bucket is not None
69+
self.centrifuge.at_bucket.assign_child_resource(self.plate)
70+
another_plate = Cor_96_wellplate_360ul_Fb(name="another_plate")
71+
self.loader.assign_child_resource(another_plate)
72+
with self.assertRaises(BucketHasPlateError):
73+
await self.loader.load()
74+
self.mock_loader_backend.load.assert_not_awaited()
75+
76+
async def test_load_not_at_bucket(self):
77+
self.loader.assign_child_resource(self.plate)
78+
await self.centrifuge.open_door()
79+
with self.assertRaises(NotAtBucketError):
80+
await self.loader.load()
81+
self.mock_loader_backend.load.assert_not_awaited()
82+
83+
async def test_unload(self):
84+
await self.centrifuge.go_to_bucket1()
85+
await self.centrifuge.open_door()
86+
assert self.centrifuge.at_bucket is not None
87+
self.centrifuge.at_bucket.assign_child_resource(self.plate)
88+
await self.loader.unload()
89+
self.mock_loader_backend.unload.assert_awaited_once()
90+
self.assertEqual(self.centrifuge.at_bucket.children, [])
91+
self.assertEqual(self.loader.children, [self.plate])
92+
93+
async def test_unload_locked_door(self):
94+
self.loader.assign_child_resource(self.plate)
95+
with self.assertRaises(CentrifugeDoorError):
96+
await self.loader.unload()
97+
self.mock_loader_backend.unload.assert_not_awaited()
98+
99+
async def test_unload_bucket_has_no_plate(self):
100+
await self.centrifuge.go_to_bucket1()
101+
await self.centrifuge.open_door()
102+
with self.assertRaises(BucketNoPlateError):
103+
await self.loader.unload()
104+
self.mock_loader_backend.unload.assert_not_awaited()
105+
106+
async def test_unload_loader_has_plate(self):
107+
await self.centrifuge.go_to_bucket1()
108+
await self.centrifuge.open_door()
109+
self.loader.assign_child_resource(self.plate)
110+
with self.assertRaises(BucketNoPlateError):
111+
await self.loader.unload()
112+
self.mock_loader_backend.unload.assert_not_awaited()
113+
114+
async def test_unload_not_at_bucket(self):
115+
self.loader.assign_child_resource(self.plate)
116+
await self.centrifuge.open_door()
117+
with self.assertRaises(NotAtBucketError):
118+
await self.loader.unload()
119+
self.mock_loader_backend.unload.assert_not_awaited()

pylabrobot/centrifuge/standard.py

+16
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,18 @@
11
class LoaderNoPlateError(Exception):
22
pass
3+
4+
5+
class CentrifugeDoorError(Exception):
6+
pass
7+
8+
9+
class NotAtBucketError(Exception):
10+
pass
11+
12+
13+
class BucketNoPlateError(Exception):
14+
pass
15+
16+
17+
class BucketHasPlateError(Exception):
18+
pass

0 commit comments

Comments
 (0)