Skip to content

Commit bff2218

Browse files
BioCamclaude
andcommitted
STARBackend: bound X-arm and channel moves to the resolved x_range
Enforce the per-arm drive range from XArmInformation on the two X-move entry points. experimental_x_arm_move and move_channel_x now call _check_x_arm_reachable, which reads the x_arm_information property (raising if setup has not resolved the ranges yet) and rejects a target outside the arm's x_range with a clear ValueError before any wire command is sent. Replaces the hardcoded 90-1350mm literal in experimental_x_arm_move, and drops the STARChatterboxBackend move_channel_x stub so the bounds check runs in simulation too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5f2a7d4 commit bff2218

3 files changed

Lines changed: 109 additions & 8 deletions

File tree

pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1743,6 +1743,9 @@ def _ops_to_fw_positions(
17431743
self, ops: Sequence[PipettingOp], use_channels: List[int]
17441744
) -> Tuple[List[int], List[int], List[bool]]:
17451745
x_positions, y_positions, channels_involved = super()._ops_to_fw_positions(ops, use_channels)
1746+
# TODO: also bound each involved channel's x against the arm's resolved x_range here,
1747+
# raising once with every non-compliant (channel, x) pair. Extends the primitive guard
1748+
# in `_check_x_arm_reachable` to the high-level pipetting ops that route through here.
17461749
if self.left_side_panel_installed:
17471750
min_x = round(self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL * 10)
17481751
for x, involved in zip(x_positions, channels_involved):
@@ -2264,13 +2267,13 @@ async def experimental_x_arm_move(
22642267
"""Move the X-arm to an absolute X position with specified acceleration.
22652268

22662269
Args:
2267-
x: Target X coordinate in mm. Must be between 90.0 and 1350.0.
2270+
x: Target X coordinate in mm. Must be within the arm's reachable X range
2271+
(see the X-drive's `x_range`).
22682272
acceleration_level: Acceleration index (hardware units), 1-5. Default 3.
22692273
current_protection_limiter: Motor current limit (hardware units), 0-7. Default 7.
22702274
"""
22712275

2272-
if not (90.0 <= x <= 1350.0):
2273-
raise ValueError(f"x must be between 90.0 and 1350.0 mm, is {x}")
2276+
self._check_x_arm_reachable(x)
22742277
if not (1 <= acceleration_level <= 5):
22752278
raise ValueError(f"acceleration_level must be between 1 and 5, is {acceleration_level}")
22762279
if not (0 <= current_protection_limiter <= 7):
@@ -2286,6 +2289,35 @@ async def experimental_x_arm_move(
22862289
lw=str(current_protection_limiter),
22872290
)
22882291

2292+
def _check_x_arm_reachable(self, x: float, position: Literal["left", "right"] = "left") -> None:
2293+
"""Raise if x (mm) is outside the X-drive's travel range (``x_range``).
2294+
2295+
``x`` is the arm's position at its reference point (its center for a dual-rail arm, the
2296+
right edge for a single-rail arm), so the bound is that point's travel range ``x_range``.
2297+
2298+
Args:
2299+
x: Target X coordinate in mm.
2300+
position: Which X-arm the move drives. Defaults to the left (main) arm.
2301+
2302+
Reading ``extended_conf`` already raises if setup() has not run.
2303+
2304+
Raises:
2305+
RuntimeError: If the installed arm's geometry was not resolved.
2306+
ValueError: If no such arm is installed, or x is outside its ``x_range``.
2307+
"""
2308+
arm = (
2309+
self.extended_conf.left_x_drive if position == "left" else self.extended_conf.right_x_drive
2310+
)
2311+
if arm is None:
2312+
raise ValueError(f"No {position} X-arm is installed.")
2313+
if arm.x_range is None:
2314+
raise RuntimeError(f"{position} X-arm geometry not resolved")
2315+
x_min, x_max = arm.x_range
2316+
if not x_min <= x <= x_max:
2317+
raise ValueError(
2318+
f"{position} X-arm x={x}mm is outside its drive travel range [{x_min}, {x_max}]mm."
2319+
)
2320+
22892321
# # # # Single-Channel Pipette Commands # # # #
22902322

22912323
# # # Machine Query (MEM-READ) Commands: Single-Channel # # #
@@ -5131,6 +5163,7 @@ async def move_channel_x(self, channel: int, x: float):
51315163
f"PIP channel x={x}mm is below the minimum {self.PIP_X_MIN_WITH_LEFT_SIDE_PANEL}mm "
51325164
f"(left side panel is installed)"
51335165
)
5166+
self._check_x_arm_reachable(x)
51345167
await self.position_left_x_arm_(round(x * 10))
51355168

51365169
@need_iswap_parked

pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -331,9 +331,6 @@ async def channels_request_y_minimum_spacing(self) -> List[float]:
331331
async def move_channel_y(self, channel: int, y: float):
332332
logger.info("moving channel %s to y: %s", channel, y)
333333

334-
async def move_channel_x(self, channel: int, x: float):
335-
logger.info("moving channel %s to x: %s", channel, x)
336-
337334
async def move_all_channels_in_z_safety(self):
338335
logger.info("moving all channels to z safety")
339336

pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import datetime
66
import unittest
77
import unittest.mock
8+
from dataclasses import replace
89
from typing import Literal, cast
910

1011
from pylabrobot.arms.standard import CartesianCoords
@@ -32,7 +33,7 @@
3233
)
3334
from pylabrobot.resources.barcode import Barcode
3435
from 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

3738
from .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+
26192651
class 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

Comments
 (0)