Skip to content

Make Tip a HeadTool resource, build StandingTipRack, introduce Hamilton NTRs - #1271

Open
BioCam wants to merge 11 commits into
PyLabRobot:mainfrom
BioCam:create-HeadTool
Open

BioCam wants to merge 11 commits into
PyLabRobot:mainfrom
BioCam:create-HeadTool

Conversation

@BioCam

@BioCam BioCam commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

A tip is not a resource on main. It lives only inside its spot's TipTracker, so nothing in the tree says where a tip is, the backends each re-derive its position from the spot, and a channel carrying a tip has no geometry below it. The CO-RE grip tool, which a channel picks up exactly like a tip, has no model at all. StandingTipRack is a stub, and the only nested tip rack, a NestedTipRack for the 50 µL NTR, has its tip spots where the instrument doesn't put them.

This PR

Tip is a HeadTool resource

  • HeadTool(Resource): what a channel carries. It has fitting_depth, collar_height, pick_up_location (the centre of the top of the opening the channel enters) and kind(), which is equal for tools a backend can use interchangeably and keys the firmware tip-type tables.
  • Tip is a HeadTool. HamiltonTip takes its diameter and collar_height from tip_size.
  • HamiltonCoreGripperTool / hamilton_core_gripper_tool model the grip tool, with total_length = 30 mm to its grip line, as the firmware counts it.
  • A tip is a child of its TipSpot: centred, with its pick-up location collar_height above the spot. A mounted tool is a child of its TipMountingShaft: centred, with its pick-up location fitting_depth above the shaft's end. Resource.comparable_children() keeps a carried tool out of equality, so a spot with a tip still equals one without.
  • The STAR simulator answers tip presence and lowest point from the shafts.

StandingTipRack

  • A tip rack that stands on its own rather than sinking into a holder, with an optional stacking_z_height. NestedTipRack is deprecated.
  • ResourceStack nests standing tip racks by their stacking_z_height, as it does plates.
  • LiquidHandler refuses to pick up tips from, or drop tips to, a rack with a lid or another rack on top of it (TipRack._available).

Hamilton NTRs

  • Hamilton racks come from two bodies, and every catalogue rack is a body plus its tip:
    • hamilton_96_tiprack_standard: renamed from hamilton_tiprack_standard, with a deprecated alias. Its spots are the 7.2 mm holes, with the same centres.
    • hamilton_96_tiprack_ntr: an SLAS footprint with SLAS tip positions and spots at the collar seat.
  • hamilton_96_tiprack_10uL_NTR and _300uL_NTR are new. _50uL_NTR's spots move from 13.5 to 55 mm.
  • hamilton_tip_carrier_L5_ntr_a00 (182074) replaces TIP_CAR_NTR_A00, which remains as a deprecated alias, and keeps Hamilton's sites. hamilton_mfx_module_tiprackholder_ntr (191425) is new.

Behaviour:

  • Unchanged: positions and firmware for embedded racks.
  • Changed:
    • tips compare by name (use kind() to compare types);
    • a serialized tip includes its size and pick_up_location;
    • racked tips appear in get_all_children().
  • Firmware: the Vantage discard now takes the tip end from NTR spots, as it does from embedded ones.

Tests:

  • test_nested_tip_racks_ground_truth: for the 10, 50 and 300 µL NTRs, on both the MFX NTR4 module and the NTR carrier, the channel and 96-head pick-up and drop commands match what Hamilton's own software sends.
  • TipSpot and shaft placement, grip tool geometry, kind(), stacked-rack nesting and the covered-rack guard.

Full suite passes (3410 passed, 4 skipped). ruff format, ruff check --select I, ruff check and typos are clean. mypy shows only the existing LI-COR test errors on main.

This touches TipSpot.__init__ next to #1251, so whichever lands second carries a small merge.

🤖 Generated with Claude Code

@BioCam
BioCam requested a review from a team as a code owner September 17, 2026 00:38
@BioCam
BioCam requested a review from rickwierenga September 17, 2026 00:40
…Hamilton NTRs

- `HeadTool(Resource)`: what a channel carries, with `fitting_depth`,
  `collar_height`, `pick_up_location` and `kind()`. `Tip` is a `HeadTool`;
  `HamiltonCoreGripperTool` models the CO-RE grip tool.
- Tips are children of their `TipSpot` or `TipMountingShaft`, placed from
  their pick-up location. `Resource.comparable_children()` keeps a carried
  tool out of equality. The STAR simulator reads tips from the shafts, and the
  STAR driver keys its tip type table on `kind()`.
- `StandingTipRack` with `stacking_z_height`; `ResourceStack` nests standing
  tip racks; `NestedTipRack` is deprecated. `LiquidHandler` refuses racks with
  something stacked on them.
- Hamilton racks are built on `hamilton_96_tiprack_standard` (renamed from
  `hamilton_tiprack_standard`) and `hamilton_96_tiprack_ntr`. There are new
  10 uL and 300 uL NTRs, the 50 uL NTR's spots are corrected, and
  `hamilton_tip_carrier_L5_ntr_a00` (renamed from `TIP_CAR_NTR_A00`) and
  `hamilton_mfx_resource_holder_ntr4` are added.
- `test_nested_tip_racks_ground_truth` checks the NTR pick-up and drop
  firmware against Venus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The names point at meshes this branch does not ship; they stay on v1-master-branch, where the meshes
are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@BioCam

BioCam commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author
Screenshot 2026-09-17 at 01 53 35

modelling physical reality

BioCam and others added 7 commits September 17, 2026 08:16
A model name says which product a tip is, as a rack's does; it does not need a mesh to exist. This
reverts edaaf7e.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…or the racks it holds

Renamed from `hamilton_mfx_resource_holder_ntr4`, which has not been merged, so no alias. Comments
say where a value comes from as Hamilton's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The modules measured from the deck subtract 18.2, so every rack and plate stays where it was, and the
NTR pick-up and drop commands are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…easured

MFX modules are 134 mm across and are modelled as the 135 mm their carrier slot is, since the
track's sliding blocks centre them in it. The rack stays where it was, so the NTR pick-up and drop
commands are unchanged. Probing with Z-touch and cLLD against the deck put the module top at 38.1 mm
and the rack seat at 29.1 mm above the deck, as modelled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tip rack holder is main's `hamilton_mfx_tiprackholder_standard`, at this branch's 18.2 mm carrier
sites; the NTR4 module keeps its own definition, with its rack centred in the module's pocket as the
0.5 mm forward sites need.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The raised racks take this branch's tip spot convention: the 7.2 mm hole, centred on the rack, so
their spots stand where they did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines 1117 to 1137
ops=[
Drop(
self.deck.get_trash_area(),
tip=tips[3],
tip=tips[0],
offset=offsets[0],
),
Drop(
self.deck.get_trash_area(),
tip=tips[2],
tip=tips[1],
offset=offsets[1],
),
Drop(
self.deck.get_trash_area(),
tip=tips[1],
tip=tips[2],
offset=offsets[2],
),
Drop(
self.deck.get_trash_area(),
tip=tips[0],
tip=tips[3],
offset=offsets[3],
),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems wrong? it is dangerously changing the behavior of legacy

Comment on lines +50 to +56
# What the machine has to be told about this tool, in the same tip type table a tip goes in:
# a grip tool holds no liquid, but the table demands at least 1.0 uL, and the value plays no
# part in picking the tool up.
self.has_filter = False
self.maximal_volume = 1.0
self.tip_size = TipSize.UNDEFINED
self.pickup_method = TipPickupMethod.OUT_OF_RACK

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does it have a volume?

Comment on lines 255 to 260

def comparable_children(self) -> List["Resource"]:
"""The children compared in equality: all of them, unless a subclass holds some as state."""
return self.children

def __eq__(self, other):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is ugly. why do we need it?

Comment on lines 115 to +122
if (
self.direction == "z"
and isinstance(upper, Plate)
isinstance(upper, Plate)
and upper.stacking_z_height is not None
and isinstance(lower, Plate)
and lower.lid is None
) or (
isinstance(upper, StandingTipRack)
and upper.stacking_z_height is not None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future I would like a NestedTipRackStack(ResourceStack) subclass that has a counting feature, but that is for a follow up

Comment on lines -68 to -91
def __hash__(self):
return hash(
(
self.has_filter,
self.total_tip_length,
self.nominal_volume,
self.maximal_volume,
self.fitting_depth,
self._collar_height,
)
)

def __eq__(self, other: object) -> bool:
if not isinstance(other, Tip):
return NotImplemented

return (
self.has_filter == other.has_filter
and self.total_tip_length == other.total_tip_length
and self.nominal_volume == other.nominal_volume
and self.maximal_volume == other.maximal_volume
and self.fitting_depth == other.fitting_depth
and self._collar_height == other._collar_height
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why remove this?

Comment on lines +121 to +123
}
for held_by_the_holder in ("location", "parent_name"):
data.pop(held_by_the_holder, None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this?

Comment on lines +98 to +109
@property
def collar_height(self) -> float:
"""Return collar_height, raising if it is None."""
if self._collar_height is None:
raise ValueError(f"collar_height is not defined for this tool: {self!r}")
return self._collar_height

@property
def has_collar_height(self) -> bool:
"""Whether this tool states the height of its collar."""
return self._collar_height is not None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this like this? why not just check .collar_height is None

Comment on lines +93 to +97
@property
def is_named(self) -> bool:
"""Whether this tool has a name."""
return self._is_named

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't resources always have names? what is the reason they dont have it here? locations where tips spawn like tip spots should probably have a counter for making unique names

Comment on lines +209 to +212
@property
def _available(self) -> bool:
"""Whether nothing, a lid or another rack in its stack, sits on top of this rack."""
# A rack's spots are assigned when it is made; anything put on the rack comes after them.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this name is a bit confusing. how about _can_accept_op or sth

Comment on lines 114 to +121
def serialize(self) -> dict:
"""Serialize the tip spot."""
return {
"""Serialize the tip spot. Its tip is state, not a serialized child."""
data = {
**super().serialize(),
"prototype_tip": self.make_tip().serialize(),
}
data.pop("children", None)
return data

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why exclude children?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants