55import datetime
66import unittest
77import unittest .mock
8+ from dataclasses import replace
89from typing import Literal , cast
910
1011from pylabrobot .arms .standard import CartesianCoords
3233)
3334from pylabrobot .resources .barcode import Barcode
3435from pylabrobot .resources .greiner import Greiner_384_wellplate_28ul_Fb
35- from pylabrobot .resources .hamilton import STARLetDeck , hamilton_96_tiprack_300uL_filter
36+ from pylabrobot .resources .hamilton import STARDeck , STARLetDeck , hamilton_96_tiprack_300uL_filter
3637
3738from .STAR_backend import (
3839 CommandSyntaxError ,
@@ -1955,7 +1956,17 @@ async def asyncSetUp(self):
19551956
19561957 self .star ._num_channels = 8
19571958 self .star ._machine_conf = _DEFAULT_MACHINE_CONFIGURATION
1958- self .star ._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION
1959+ # setup() is mocked out below, so seed the left X-drive geometry it would normally
1960+ # resolve; the foil ops move channels in X, which is now bounds-checked against x_range.
1961+ self .star ._extended_conf = replace (
1962+ _DEFAULT_EXTENDED_CONFIGURATION ,
1963+ left_x_drive = replace (
1964+ _DEFAULT_EXTENDED_CONFIGURATION .left_x_drive ,
1965+ width = 370.0 ,
1966+ x_range = (95.0 , 1337.5 ),
1967+ workspace_range = (- 323.2 , 1337.5 ),
1968+ ),
1969+ )
19591970 self .star .setup = unittest .mock .AsyncMock ()
19601971 self .star ._core_parked = True
19611972 self .star ._iswap_parked = True
@@ -2616,6 +2627,27 @@ def test_model_and_reference_by_width(self):
26162627 self .assertEqual (single .reference_point , "right" )
26172628
26182629
2630+ class TestXArmReachByDeck (unittest .IsolatedAsyncioTestCase ):
2631+ """A STAR reaches farther in X than a STARLet: an X past the STARLet's right edge
2632+ is reachable on a STAR but rejected on a STARLet."""
2633+
2634+ async def test_x_past_starlet_edge_reachable_on_star_only (self ):
2635+ star = LiquidHandler (STARChatterboxBackend (), deck = STARDeck ())
2636+ await star .setup ()
2637+ starlet = LiquidHandler (STARChatterboxBackend (), deck = STARLetDeck ())
2638+ await starlet .setup ()
2639+
2640+ star_range = star .backend .extended_conf .left_x_drive .x_range
2641+ starlet_range = starlet .backend .extended_conf .left_x_drive .x_range
2642+ assert star_range is not None and starlet_range is not None
2643+ self .assertGreater (star_range [1 ], starlet_range [1 ])
2644+
2645+ x = (starlet_range [1 ] + star_range [1 ]) / 2 # past the STARLet edge, within STAR reach
2646+ star .backend ._check_x_arm_reachable (x ) # reachable on STAR: no raise
2647+ with self .assertRaises (ValueError ):
2648+ starlet .backend ._check_x_arm_reachable (x )
2649+
2650+
26192651class TestXArmRangeQueries (unittest .IsolatedAsyncioTestCase ):
26202652 """RU/UA parse the firmware replies observed on real machines."""
26212653
@@ -2632,3 +2664,42 @@ async def test_working_envelopes_per_arm(self):
26322664 self .star .send_command .return_value = "C0UAid0001er00/00ua5952 0000 -03232 +15172 +30000 +30000"
26332665 wraps = await self .star .request_working_envelopes_per_arm ()
26342666 self .assertEqual (wraps , {"left" : (595.2 , (- 323.2 , 1517.2 )), "right" : (0.0 , (3000.0 , 3000.0 ))})
2667+
2668+
2669+ class TestXArmRangeEnforcement (unittest .IsolatedAsyncioTestCase ):
2670+ """move_channel_x / experimental_x_arm_move reject targets outside the arm's x_range."""
2671+
2672+ def setUp (self ):
2673+ self .star = STARBackend ()
2674+ # setup() normally resolves this from firmware; seed the left X-drive geometry so the
2675+ # reachability checks have a range to enforce against, without needing a deck-mounted
2676+ # arm or tracker.
2677+ self .star ._extended_conf = replace (
2678+ _DEFAULT_EXTENDED_CONFIGURATION ,
2679+ left_x_drive = replace (
2680+ _DEFAULT_EXTENDED_CONFIGURATION .left_x_drive ,
2681+ width = 370.0 ,
2682+ x_range = (95.0 , 1337.5 ),
2683+ workspace_range = (- 323.2 , 1337.5 ),
2684+ ),
2685+ )
2686+ self .star .send_command = unittest .mock .AsyncMock (return_value = {})
2687+
2688+ async def test_experimental_x_arm_move_rejects_out_of_range (self ):
2689+ # x_range is (95.0, 1337.5): both ends reject, and no wire command is sent.
2690+ for x in (94.0 , 1400.0 ):
2691+ with self .assertRaises (ValueError ):
2692+ await self .star .experimental_x_arm_move (x )
2693+ self .star .send_command .assert_not_awaited ()
2694+
2695+ async def test_experimental_x_arm_move_in_range_sends_command (self ):
2696+ # A target inside x_range passes the guard and reaches the wire unchanged.
2697+ await self .star .experimental_x_arm_move (500.0 )
2698+ self .star .send_command .assert_awaited_once_with (
2699+ module = "X0" , command = "XP" , la = "05000" , lr = "3" , lw = "7"
2700+ )
2701+
2702+ async def test_move_channel_x_rejects_out_of_range (self ):
2703+ with self .assertRaises (ValueError ):
2704+ await self .star .move_channel_x (0 , 1400.0 )
2705+ self .star .send_command .assert_not_awaited ()
0 commit comments