Skip to content

Add Legacy Starlet Support#1002

Open
har1eyk wants to merge 3 commits intoPyLabRobot:mainfrom
har1eyk:fix/starlet-old-firmware-compat
Open

Add Legacy Starlet Support#1002
har1eyk wants to merge 3 commits intoPyLabRobot:mainfrom
har1eyk:fix/starlet-old-firmware-compat

Conversation

@har1eyk
Copy link
Copy Markdown
Contributor

@har1eyk har1eyk commented Apr 20, 2026

This PR adds compatibility support for STARlet systems running May 2009 legacy firmware (6.2S 2009-05-04). It updates CoRe 8, CoRe 96, and CoRe gripper handling for older firmware command sets and reported geometry, and adds regression coverage for the legacy behaviors. On hardware, CoRe 8, CoRe 96, and CoRe gripper operations were verified to work.

@har1eyk
Copy link
Copy Markdown
Contributor Author

har1eyk commented Apr 21, 2026

Error with this running on non-legacy units.

STARFirmwareError                         Traceback (most recent call last)
/tmp/ipykernel_1896901/733193719.py in ?()
----> 1 await lh.setup(skip_autoload=True)
      2 
      3 # # STARlet without iSWAP reports 0 arms; keep Co-Re gripper bookkeeping available.
      4 # if lh.backend.num_arms == 0:

~/Documents/Hamilton-Starlet/pylabrobot/pylabrobot/liquid_handling/liquid_handler.py in ?(self, **backend_kwargs)
    162       raise RuntimeError("The setup has already finished. See `LiquidHandler.stop`.")
    163 
    164     self.backend.set_deck(self.deck)
    165     self.backend.set_heads(head=self.head, head96=self.head96)
--> 166     await super().setup(**backend_kwargs)
    167 
    168     self.head = {c: TipTracker(thing=f"Channel {c}") for c in range(self.backend.num_channels)}
    169 

~/Documents/Hamilton-Starlet/pylabrobot/pylabrobot/machines/machine.py in ?(self, **backend_kwargs)
     63   async def setup(self, **backend_kwargs):
---> 64     await self.backend.setup(**backend_kwargs)
     65     self._setup_finished = True

~/Documents/Hamilton-Starlet/pylabrobot/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py in ?(self, skip_instrument_initialization, skip_pip, skip_autoload, skip_iswap, skip_core96_head)
   1841       await set_up_pip()
   1842       await set_up_iswap()
   1843       await set_up_core96_head()
   1844 
-> 1845     await asyncio.gather(set_up_autoload(), set_up_arm_modules())
   1846 
   1847     # After setup, STAR will have thrown out anything mounted on the pipetting channels, including
   1848     # the core grippers.

~/Documents/Hamilton-Starlet/pylabrobot/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py in ?()
   1840     async def set_up_arm_modules():
-> 1841       await set_up_pip()
   1842       await set_up_iswap()
   1843       await set_up_core96_head()

~/Documents/Hamilton-Starlet/pylabrobot/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py in ?()
   1791         await self.initialize_pip()
   1792       self._channels_minimum_y_spacing = await self.channels_request_y_minimum_spacing()
   1793 
   1794       # Cache per-channel hardware configuration for version-specific behavior
-> 1795       self._pip_channel_information = [
   1796         await self._pip_channel_request_configuration(ch) for ch in range(self.num_channels)
   1797       ]

~/Documents/Hamilton-Starlet/pylabrobot/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py in ?(self, channel)
   1537 
   1538     Args:
   1539       channel: 0-indexed channel number.
   1540     """
-> 1541     resp: str = await self.send_command(STARBackend.channel_id(channel), "VW")
   1542     hw_tokens = resp.split("vw")[-1].strip().split()
   1543     return PipChannelInformation(
   1544       channel_type="ML_STAR_RPC" if hw_tokens[0] == "1" else "ML_STAR",

~/Documents/Hamilton-Starlet/pylabrobot/pylabrobot/liquid_handling/backends/hamilton/base.py in ?(self, module, command, auto_id, tip_pattern, write_timeout, read_timeout, wait, fmt, **kwargs)
    262       tip_pattern=tip_pattern,
    263       auto_id=auto_id,
    264       **kwargs,
    265     )
--> 266     resp = await self._write_and_read_command(
    267       id_=id_,
    268       cmd=cmd,
    269       write_timeout=write_timeout,

~/Documents/Hamilton-Starlet/pylabrobot/pylabrobot/liquid_handling/backends/hamilton/base.py in ?(self, id_, cmd, write_timeout, read_timeout, wait)
    294 
    295     loop = asyncio.get_event_loop()
    296     fut: asyncio.Future[str] = loop.create_future()
    297     self._start_reading(id_, loop, fut, cmd, read_timeout)
--> 298     result = await fut
    299     return result

~/Documents/Hamilton-Starlet/pylabrobot/pylabrobot/liquid_handling/backends/hamilton/base.py in ?(self)
    358           )
    359           del self._waiting_tasks[idx]
    360 
    361       if len(self._waiting_tasks) == 0:
--> 362         await asyncio.sleep(0.01)
    363         continue
    364 
    365       try:

~/Documents/Hamilton-Starlet/pylabrobot/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py in ?(self, resp)
   1637           he.errors[
   1638             module_name
   1639           ].message += " (call lh.backend.request_name_of_last_faulty_parameter)"
   1640 
-> 1641       raise he

STARFirmwareError: {'Pipetting channel 1': UnknownHamiltonError('Unknown command')}, P1VWid0016er30```

@har1eyk har1eyk closed this Apr 21, 2026
@har1eyk
Copy link
Copy Markdown
Contributor Author

har1eyk commented Apr 21, 2026

This PR works with firmware:

  1. May 2009 (6.2S 2009-05-04)
  2. April 2015 (7.2S D 2015-04-01)

@har1eyk har1eyk reopened this Apr 21, 2026
@rickwierenga
Copy link
Copy Markdown
Member

Swapped the _pip_has_old_firmware / _core96_has_old_firmware wrappers for inline _pip_firmware_version >= datetime.date(2010, 1, 1) checks at each capability helper — "old" is ambiguous and doesn't scale if a second firmware cutoff lands later (old vs. very old vs. old-but-younger). Also pulled the four cross-cutting getattr branches out of the generic liquid_handler.py / hamilton/base.py so backend-specific firmware quirks stay in STARBackend.

@rickwierenga rickwierenga force-pushed the fix/starlet-old-firmware-compat branch from b825083 to 85abf7f Compare April 22, 2026 05:18
@rickwierenga
Copy link
Copy Markdown
Member

could you please explain why you removed the 9mm check for the 2009 version? is it because the channels are in reverse order?

@rickwierenga
Copy link
Copy Markdown
Member

also I am understanding correctly with the channels being reversed, channel 0 is now at the front of the robot? while this makes more sense, I would actually propose just unifying this in PLR so that channel 0 always refers to the back. we have done this for all robots so far, based on this being the case historically. I would propose also doing it for this older firmware version (abstracting over their api) so that code works more smoothly between different firmware versions and with the shared plr logic

@har1eyk har1eyk force-pushed the fix/starlet-old-firmware-compat branch from 1a5db95 to c1ac38f Compare April 23, 2026 13:18
@har1eyk
Copy link
Copy Markdown
Contributor Author

har1eyk commented Apr 24, 2026

could you please explain why you removed the 9mm check for the 2009 version? is it because the channels are in reverse order?

The 9mm bypass was an accidental legacy-firmware relaxation. The legacy firmware errs when aspirating from troughplates. I can do one tip-at-a-time, but never all tips simultaneously from a troughplate. The PR has been rolled back.

har1eyk added 2 commits April 24, 2026 11:44
grippers work
CORE8 successful, but resource min spacing error in troughplates
It's a game of whack-a-mole determining which parameters are accepted by the legacy
firmware and which commands are not.

H0DQ is a pre-pickup dispensing-drive move. Modified so that it only runs on firmware that supports it.
po gating applied with aspirate and dispense.
legacy firmware command tests to assert legacy PIP AS omits po, gi, gj, gk, lk, ik, sd, se, sz, and io.
@har1eyk har1eyk force-pushed the fix/starlet-old-firmware-compat branch from c1ac38f to 8c66d05 Compare April 24, 2026 17:40
@har1eyk
Copy link
Copy Markdown
Contributor Author

har1eyk commented Apr 24, 2026

also I am understanding correctly with the channels being reversed, channel 0 is now at the front of the robot? while this makes more sense, I would actually propose just unifying this in PLR so that channel 0 always refers to the back. we have done this for all robots so far, based on this being the case historically. I would propose also doing it for this older firmware version (abstracting over their api) so that code works more smoothly between different firmware versions and with the shared plr logic

Nice catch, Rick. I don't know how the channels got reversed. Again, I think this was a unintended modification committed during troubleshooting.

@har1eyk
Copy link
Copy Markdown
Contributor Author

har1eyk commented Apr 24, 2026

I found unintended changes in the original branch, so I rebuilt this PR branch from the verified minimal legacy-firmware update.

The diff is now limited to STAR_backend.py and STAR_tests.py, preserving the legacy Starlet compatibility behavior while removing unrelated changes. I force-updated the branch with --force-with-lease to keep the existing PR discussion and CI context attached to the clean version.

@rickwierenga , will you kindly review?

Comment on lines +1721 to +1726

def _pip_y_bounds(self) -> Tuple[float, float]:
"""Return PIP Y bounds as a normalized ``(min_y, max_y)`` tuple."""
min_y = self.extended_conf.left_arm_min_y_position
max_y = self.extended_conf.pip_maximal_y_position
return min(min_y, max_y), max(min_y, max_y)
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 would the max y be lower than min y? i do not understand why they are being compared here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

why would the max y be lower than min y? i do not understand why they are being compared here

Normally it should not be. This is handling a legacy STARlet firmware quirk we observed on hardware: the extended configuration can report invalid/reversed PIP Y bounds, for example left_arm_min_y_position = 606.5 and pip_maximal_y_position = 6.0.

That bad interval made ensure_can_reach_position() reject valid CORE8 positions during tip pickup. _normalize_extended_configuration_y_bounds() fixes the known invalid values during setup, and _pip_y_bounds() defensively returns the bounds as an ordered (min_y, max_y) tuple for reachability/safety call sites.

So the comparison is not because max is expected to be lower than min on valid firmware; it is there to tolerate bad legacy firmware-reported bounds without disabling the reachability guard entirely.

Copy link
Copy Markdown
Member

@rickwierenga rickwierenga Apr 27, 2026

Choose a reason for hiding this comment

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

is that the only problem or are the channels actually reversed as originally reported? does higher y still mean towards the back of the robot? is channel 0 still the backmost channel?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I ran a hardware confirmation script on the legacy STARlet to answer the channel/Y-axis questions directly.

This PR does not reverse channel order or change the deck coordinate model. After setup, the legacy firmware-reported PIP Y bounds were normalized to:

  • left_arm_min_y_position: 6.00 mm
  • pip_maximal_y_position: 606.50 mm
  • normalized interval: (6.00, 606.50) mm

So the change is only correcting invalid firmware-reported bounds before reachability checks.

For CORE8 channel mapping, PLR mapped tip_rack_1000["A8:H8"] with use_channels=[0..7] as:

  • channel 0 -> A8, Y 337.8 mm
  • channel 1 -> B8, Y 328.8 mm
  • channel 2 -> C8, Y 319.8 mm
  • channel 3 -> D8, Y 310.8 mm
  • channel 4 -> E8, Y 301.8 mm
  • channel 5 -> F8, Y 292.8 mm
  • channel 6 -> G8, Y 283.8 mm
  • channel 7 -> H8, Y 274.8 mm

This confirms:

  1. channels are not reversed
  2. higher Y is still treated as toward the back of the robot
  3. channel 0 remains the backmost channel

I also checked that the reachability guard was not bypassed. Valid A8:H8 pickup targets all returned reachable=True, while deliberately invalid targets still failed:

  • channel 0 at y=-20.0 mm: reachable=False
  • channel 7 at y=800.0 mm: reachable=False

So ensure_can_reach_position() remains active. This PR fixes bad legacy firmware inputs to the reachability logic; it does not suppress the guard or change channel numbering/direction.

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