Skip to content

Commit 83b5321

Browse files
BioCamclaude
andcommitted
STARBackend: track the X-arm carriage via a deck-owned XArm resource
Add carriage-position tracking for the X-arm. The deck owns an `XArm` resource per present arm (created at setup from x_arm_information), and the `XArm` owns an `XArmTracker` whose x is the resource's state - so the backend drives it from firmware and the Visualizer reads it through the standard state channel, like a TipSpot owns a TipTracker. The lowest-level X moves and position queries update the tracker: experimental_x_arm_move, position_left_x_arm_, position_right_x_arm_ commit the commanded target (invalidating on a failed move), and request_left_x_arm_position / request_right_x_arm_position record the read-back value. get_x_arm_position reports the tracked (left, right) reference points, None while unknown. The Visualizer draws the arm as a full-deck-height frame with a reference line at the tracked x, gliding on update. STARChatterboxBackend reports a simulated resting home in place of the position read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1be17e2 commit 83b5321

8 files changed

Lines changed: 646 additions & 20 deletions

File tree

pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py

Lines changed: 122 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,15 @@
9999
)
100100
from pylabrobot.resources.hamilton.hamilton_decks import (
101101
HamiltonCoreGrippers,
102+
HamiltonSTARDeck,
102103
rails_for_x_coordinate,
103104
)
104105
from pylabrobot.resources.liquid import Liquid
105106
from pylabrobot.resources.rotation import Rotation
106107
from pylabrobot.resources.tip_tracker import does_tip_tracking
107108
from pylabrobot.resources.trash import Trash
109+
from pylabrobot.resources.x_arm import XArm
110+
from pylabrobot.resources.x_arm_tracker import XArmTracker
108111

109112
T = TypeVar("T")
110113

@@ -2230,6 +2233,8 @@ async def set_up_arm_modules():
22302233
# the core grippers.
22312234
self._core_parked = True
22322235

2236+
await self._setup_x_arms()
2237+
22332238
self._setup_done = True
22342239

22352240
async def stop(self):
@@ -2281,13 +2286,81 @@ async def experimental_x_arm_move(
22812286
f"current_protection_limiter must be between 0 and 7, is {current_protection_limiter}"
22822287
)
22832288

2284-
return await self.send_command(
2285-
module="X0",
2286-
command="XP",
2287-
la=f"{round(x * 10):05}",
2288-
lr=str(acceleration_level),
2289-
lw=str(current_protection_limiter),
2290-
)
2289+
tracker = self.left_x_arm_tracker
2290+
if tracker is not None and not tracker.is_disabled:
2291+
tracker.set_x(x, commit=False)
2292+
try:
2293+
resp = await self.send_command(
2294+
module="X0",
2295+
command="XP",
2296+
la=f"{round(x * 10):05}",
2297+
lr=str(acceleration_level),
2298+
lw=str(current_protection_limiter),
2299+
)
2300+
except Exception:
2301+
if tracker is not None and not tracker.is_disabled:
2302+
tracker.invalidate()
2303+
raise
2304+
if tracker is not None and not tracker.is_disabled:
2305+
tracker.commit()
2306+
return resp
2307+
2308+
@property
2309+
def left_x_arm_tracker(self) -> Optional[XArmTracker]:
2310+
"""The left X-arm's tracker, owned by the deck's left X-arm (created at
2311+
setup). None before setup or without a deck."""
2312+
return self._x_arm_tracker("left_x_arm")
2313+
2314+
@property
2315+
def right_x_arm_tracker(self) -> Optional[XArmTracker]:
2316+
"""The right X-arm's tracker, owned by the deck's right X-arm. None when no
2317+
right arm is installed, or before setup."""
2318+
return self._x_arm_tracker("right_x_arm")
2319+
2320+
def _x_arm_tracker(self, name: str) -> Optional[XArmTracker]:
2321+
if self._deck is not None and self._deck.has_resource(name):
2322+
x_arm = self._deck.get_resource(name)
2323+
if isinstance(x_arm, XArm):
2324+
return x_arm.tracker
2325+
return None
2326+
2327+
def get_x_arm_position(self) -> Tuple[Optional[float], Optional[float]]:
2328+
"""Tracked X-arm carriage reference points in mm, as (left, right).
2329+
2330+
Either element is None while that X-arm's position is unknown: no tracked move or
2331+
position query has completed for it yet, the last tracked move failed, or that
2332+
X-arm is not present.
2333+
"""
2334+
left_tracker = self.left_x_arm_tracker
2335+
right_tracker = self.right_x_arm_tracker
2336+
left = left_tracker.get_x() if left_tracker is not None and left_tracker.is_known else None
2337+
right = right_tracker.get_x() if right_tracker is not None and right_tracker.is_known else None
2338+
return left, right
2339+
2340+
async def _setup_x_arms(self) -> None:
2341+
"""Create the deck-owned X-arm for each present arm and seed its tracker.
2342+
2343+
The X-arm (an ``XArm``) owns its ``XArmTracker`` and reports it as state, so the
2344+
Visualizer positions it; this asks the deck to create it (sized and modelled from
2345+
the resolved X-drive configuration) and seeds the tracker from a position read (its
2346+
resting home in simulation, its true position on hardware). No-op without a deck.
2347+
"""
2348+
if self._deck is None:
2349+
return
2350+
deck = cast(HamiltonSTARDeck, self.deck)
2351+
drives = {
2352+
"left": self.extended_conf.left_x_drive,
2353+
"right": self.extended_conf.right_x_drive,
2354+
}
2355+
queries = {"left": self.request_left_x_arm_position, "right": self.request_right_x_arm_position}
2356+
for position in ("left", "right"):
2357+
arm = drives[position]
2358+
if arm is None:
2359+
continue
2360+
if arm.width is None:
2361+
raise RuntimeError(f"{position} X-arm geometry not resolved")
2362+
deck.get_or_create_x_arm(f"{position}_x_arm", arm.width, arm.model, arm.reference_point)
2363+
await queries[position]()
22912364

22922365
def _check_x_arm_reachable(self, x: float, position: Literal["left", "right"] = "left") -> None:
22932366
"""Raise if x (mm) is outside the X-drive's travel range (``x_range``).
@@ -6208,11 +6281,22 @@ async def position_left_x_arm_(self, x_position: int = 0):
62086281

62096282
assert 0 <= x_position <= 30000, "x_position_ must be between 0 and 30000"
62106283

6211-
return await self.send_command(
6212-
module="C0",
6213-
command="JX",
6214-
xs=f"{x_position:05}",
6215-
)
6284+
tracker = self.left_x_arm_tracker
6285+
if tracker is not None and not tracker.is_disabled:
6286+
tracker.set_x(x_position / 10, commit=False)
6287+
try:
6288+
resp = await self.send_command(
6289+
module="C0",
6290+
command="JX",
6291+
xs=f"{x_position:05}",
6292+
)
6293+
except Exception:
6294+
if tracker is not None and not tracker.is_disabled:
6295+
tracker.invalidate()
6296+
raise
6297+
if tracker is not None and not tracker.is_disabled:
6298+
tracker.commit()
6299+
return resp
62166300

62176301
async def position_right_x_arm_(self, x_position: int = 0):
62186302
"""Position right X-Arm
@@ -6225,11 +6309,22 @@ async def position_right_x_arm_(self, x_position: int = 0):
62256309

62266310
assert 0 <= x_position <= 30000, "x_position_ must be between 0 and 30000"
62276311

6228-
return await self.send_command(
6229-
module="C0",
6230-
command="JS",
6231-
xs=f"{x_position:05}",
6232-
)
6312+
tracker = self.right_x_arm_tracker
6313+
if tracker is not None and not tracker.is_disabled:
6314+
tracker.set_x(x_position / 10, commit=False)
6315+
try:
6316+
resp = await self.send_command(
6317+
module="C0",
6318+
command="JS",
6319+
xs=f"{x_position:05}",
6320+
)
6321+
except Exception:
6322+
if tracker is not None and not tracker.is_disabled:
6323+
tracker.invalidate()
6324+
raise
6325+
if tracker is not None and not tracker.is_disabled:
6326+
tracker.commit()
6327+
return resp
62336328

62346329
async def move_left_x_arm_to_position_with_all_attached_components_in_z_safety_position(
62356330
self, x_position: int = 0
@@ -6338,13 +6433,21 @@ async def release_all_occupied_areas(self):
63386433
async def request_left_x_arm_position(self) -> float:
63396434
"""Request left X-Arm position"""
63406435
resp_dmm = await self.send_command(module="C0", command="RX", fmt="rx#####")
6341-
return cast(float, resp_dmm["rx"]) / 10
6436+
x = cast(float, resp_dmm["rx"]) / 10
6437+
tracker = self.left_x_arm_tracker
6438+
if tracker is not None and not tracker.is_disabled:
6439+
tracker.set_x(x)
6440+
return x
63426441

63436442
async def request_right_x_arm_position(self) -> float:
63446443
"""Request right X-Arm position"""
63456444

63466445
resp_dmm = await self.send_command(module="C0", command="QX", fmt="rx#####")
6347-
return cast(float, resp_dmm["rx"]) / 10
6446+
x = cast(float, resp_dmm["rx"]) / 10
6447+
tracker = self.right_x_arm_tracker
6448+
if tracker is not None and not tracker.is_disabled:
6449+
tracker.set_x(x)
6450+
return x
63486451

63496452
async def request_maximal_ranges_of_x_drives(self) -> Dict[str, Tuple[float, float]]:
63506453
"""Request the maximal travel range of each X drive.

pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@
4545
# literal - a physical STAR reports its own value from the drive-range query.
4646
_DUAL_RAIL_LEFT_X_MIN = 95.0
4747

48+
# Simulated left X-arm resting x (mm), reported in place of a position read before any
49+
# tracked move. Small enough to sit within reach of any STAR deck. The right arm's rest
50+
# is derived from the deck instead (see _simulated_right_x_arm_home), since its reach
51+
# differs sharply between a STARlet (~800 mm) and a STAR (~1340 mm) and the two arms
52+
# must not overlap.
53+
_SIM_LEFT_X_ARM_HOME = 362.9
54+
4855
# Hamilton factory defaults. Per-machine EEPROM calibration will differ
4956
# slightly (e.g., L1=137.8, L2=137.7, STRAIGHT=-45.01 on one tested machine);
5057
# these defaults are accurate enough for simulation but not for
@@ -196,6 +203,8 @@ async def setup(
196203
else:
197204
self._iswap_information = None
198205

206+
await self._setup_x_arms()
207+
199208
async def stop(self):
200209
await LiquidHandlerBackend.stop(self)
201210
self._setup_done = False
@@ -285,6 +294,30 @@ async def request_working_envelopes_per_arm(
285294
right = (595.2, workspace) if right_installed else (0.0, (0.0, 0.0))
286295
return {"left": left, "right": right}
287296

297+
async def request_left_x_arm_position(self) -> float:
298+
# No hardware to read: report the tracked carriage position, or a resting
299+
# home before any tracked move has committed one.
300+
tracker = self.left_x_arm_tracker
301+
x = tracker.get_x() if tracker is not None and tracker.is_known else _SIM_LEFT_X_ARM_HOME
302+
if tracker is not None and not tracker.is_disabled:
303+
tracker.set_x(x)
304+
return x
305+
306+
async def request_right_x_arm_position(self) -> float:
307+
tracker = self.right_x_arm_tracker
308+
if tracker is None:
309+
raise RuntimeError("Right X-arm is not installed.")
310+
x = tracker.get_x() if tracker.is_known else self._simulated_right_x_arm_home()
311+
if not tracker.is_disabled:
312+
tracker.set_x(x)
313+
return x
314+
315+
def _simulated_right_x_arm_home(self) -> float:
316+
"""Rightmost on-deck resting x for the right X-arm in simulation, derived from the
317+
deck's reachable range so it is valid for the deck in use and clear of the left
318+
arm's rest. Positions the arm so its right edge sits at the far reachable x."""
319+
return self._simulated_x_reach_max() - self.extended_conf.right_x_arm_width / 2
320+
288321
# # # # # # # # 1_000 uL Channel: Basic Commands # # # # # # # #
289322

290323
async def request_tip_presence(self) -> List[Optional[bool]]:

0 commit comments

Comments
 (0)