4848 MultiHeadDispensePlate ,
4949 Pickup ,
5050 PickupTipRack ,
51+ PipettingOp ,
5152 ResourceDrop ,
5253 ResourceMove ,
5354 ResourcePickup ,
@@ -1180,6 +1181,8 @@ def __init__(
11801181
11811182 self ._iswap_version : Optional [str ] = None # loaded lazily
11821183
1184+ self ._setup_done = False
1185+
11831186 @property
11841187 def unsafe (self ) -> "UnSafe" :
11851188 """Actions that have a higher risk of damaging the robot. Use with care!"""
@@ -1452,8 +1455,51 @@ async def set_up_arm_modules():
14521455 # the core grippers.
14531456 self ._core_parked = True
14541457
1458+ self ._setup_done = True
1459+
1460+ async def stop (self ):
1461+ await super ().stop ()
1462+ self ._setup_done = False
1463+
1464+ @property
1465+ def setup_done (self ) -> bool :
1466+ return self ._setup_done
1467+
14551468 # ============== LiquidHandlerBackend methods ==============
14561469
1470+ def can_reach_position (self , channel_idx : int , position : Coordinate ) -> bool :
1471+ """Check if a position is reachable by a channel (center-based)."""
1472+ if not (0 <= channel_idx < self .num_channels ):
1473+ raise ValueError (f"Channel { channel_idx } is out of range for this robot." )
1474+
1475+ # frontmost channel can go to y=6, every channel after that is about 8.9 mm further back
1476+ min_y_pos = 6 + 8.9 * (self .num_channels - channel_idx - 1 )
1477+ if position .y < min_y_pos :
1478+ return False
1479+
1480+ # backmost channel can go to y=601.6, every channel before that is about 8.9 mm further forward
1481+ max_y_pos = 601.6 - 8.9 * channel_idx
1482+ if position .y > max_y_pos :
1483+ return False
1484+
1485+ return True
1486+
1487+ def ensure_can_reach_position (
1488+ self , use_channels : List [int ], ops : Sequence [PipettingOp ], op_name : str
1489+ ):
1490+ locs = [(op .resource .get_location_wrt (self .deck , y = "c" ) + op .offset ) for op in ops ]
1491+ cant_reach = [
1492+ channel_idx
1493+ for channel_idx , loc in zip (use_channels , locs )
1494+ if not self .can_reach_position (channel_idx , loc )
1495+ ]
1496+ if len (cant_reach ) > 0 :
1497+ raise ValueError (
1498+ f"Channels { cant_reach } cannot reach their target positions in '{ op_name } ' operation.\n "
1499+ "Robots with more than 8 channels have limited Y-axis reach per channel; they don't have random access to the full deck area.\n "
1500+ "Try the operation with different channels or a different target position (i.e. different labware placement)."
1501+ )
1502+
14571503 async def pick_up_tips (
14581504 self ,
14591505 ops : List [Pickup ],
@@ -1465,6 +1511,8 @@ async def pick_up_tips(
14651511 ):
14661512 """Pick up tips from a resource."""
14671513
1514+ self .ensure_can_reach_position (use_channels , ops , "pick_up_tips" )
1515+
14681516 x_positions , y_positions , channels_involved = self ._ops_to_fw_positions (ops , use_channels )
14691517
14701518 tip_spots = [op .resource for op in ops ]
@@ -1538,6 +1586,8 @@ async def drop_tips(
15381586 default if *all* tips are being dropped to a tip spot.
15391587 """
15401588
1589+ self .ensure_can_reach_position (use_channels , ops , "drop_tips" )
1590+
15411591 if drop_method is None :
15421592 if any (not isinstance (op .resource , TipSpot ) for op in ops ):
15431593 drop_method = TipDropMethod .PLACE_SHIFT
@@ -1812,6 +1862,8 @@ async def aspirate(
18121862 )
18131863 # # # delete # # #
18141864
1865+ self .ensure_can_reach_position (use_channels , ops , "aspirate" )
1866+
18151867 x_positions , y_positions , channels_involved = self ._ops_to_fw_positions (ops , use_channels )
18161868
18171869 n = len (ops )
@@ -2169,6 +2221,8 @@ async def dispense(
21692221 auto_surface_following_distance: automatically compute the surface following distance based on the container height<->volume functions. Requires liquid height to be specified or `probe_liquid_height=True`.
21702222 """
21712223
2224+ self .ensure_can_reach_position (use_channels , ops , "dispense" )
2225+
21722226 n = len (ops )
21732227
21742228 if jet is None :
0 commit comments