Skip to content

add HHS resource model #434

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pylabrobot/heating_shaking/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""A hybrid between pylabrobot.heating and pylabrobot.temperature_controlling"""

from pylabrobot.heating_shaking.backend import HeaterShakerBackend
from pylabrobot.heating_shaking.hamilton import hamilton_heater_shaker
from pylabrobot.heating_shaking.hamilton_backend import HamiltonHeaterShakerBackend
from pylabrobot.heating_shaking.heater_shaker import HeaterShaker
from pylabrobot.heating_shaking.inheco import InhecoThermoShake
137 changes: 15 additions & 122 deletions pylabrobot/heating_shaking/hamilton.py
Original file line number Diff line number Diff line change
@@ -1,126 +1,19 @@
from enum import Enum
from typing import Literal
from pylabrobot.heating_shaking.hamilton_backend import HamiltonHeaterShakerBackend
from pylabrobot.heating_shaking.heater_shaker import HeaterShaker
from pylabrobot.resources.coordinate import Coordinate

from pylabrobot.heating_shaking.backend import HeaterShakerBackend
from pylabrobot.io.usb import USB

def hamilton_heater_shaker(name: str, shaker_index: int) -> HeaterShaker:
"""Physical resource definition for a Hamilton Heater Shaker (HHS). This function returns the
universal HeaterShaker interface, pre-configured with the `HamiltonHeaterShakerBackend` backend.

class PlateLockPosition(Enum):
LOCKED = 1
UNLOCKED = 0


class HamiltonHeatShaker(HeaterShakerBackend):
"""
Backend for Hamilton Heater Shaker devices connected through an Heater Shaker Box
Hamilton cat. no.: 199034
"""

def __init__(
self,
shaker_index: int,
id_vendor: int = 0x8AF,
id_product: int = 0x8002,
) -> None:
"""
Multiple Hamilton Heater Shakers can be connected to the same Heat Shaker Box. Each has A
separate 'shaker index'
"""
assert shaker_index >= 0, "Shaker index must be non-negative"
self.shaker_index = shaker_index
self.command_id = 0

super().__init__()
self.io = USB(id_vendor=id_vendor, id_product=id_product)

async def setup(self):
"""
If io.setup() fails, ensure that libusb drivers were installed for the HHS as per docs.
"""
await self.io.setup()
await self._initialize_lock()

async def stop(self):
await self.io.stop()

def serialize(self) -> dict:
usb_serialized = self.io.serialize()
heater_shaker_serialized = HeaterShakerBackend.serialize(self)
return {
**usb_serialized,
**heater_shaker_serialized,
"shaker_index": self.shaker_index,
}

def _send_command(self, command: str, **kwargs):
assert len(command) == 2, "Command must be 2 characters long"
args = "".join([f"{key}{value}" for key, value in kwargs.items()])
self.io.write(f"T{self.shaker_index}{command}id{str(self.command_id).zfill(4)}{args}".encode())

self.command_id = (self.command_id + 1) % 10_000
return self.io.read()

async def shake(
self,
speed: float = 800,
direction: Literal[0, 1] = 0,
acceleration: int = 1_000,
):
"""
speed: steps per second
direction: 0 for positive, 1 for negative
acceleration: increments per second
"""
int_speed = int(speed)
assert 20 <= int_speed <= 2_000, "Speed must be between 20 and 2_000"
assert direction in [0, 1], "Direction must be 0 or 1"
assert 500 <= acceleration <= 10_000, "Acceleration must be between 500 and 10_000"

await self._start_shaking(direction=direction, speed=int_speed, acceleration=acceleration)

async def stop_shaking(self):
"""Shaker `stop_shaking` implementation."""
await self._stop_shaking()
await self._wait_for_stop()

async def _move_plate_lock(self, position: PlateLockPosition):
return self._send_command("LP", lp=position.value)

async def lock_plate(self):
await self._move_plate_lock(PlateLockPosition.LOCKED)

async def unlock_plate(self):
await self._move_plate_lock(PlateLockPosition.UNLOCKED)

async def _initialize_lock(self):
"""Firmware command initialize lock."""
result = self._send_command("LI")
return result

async def _start_shaking(self, direction: int, speed: int, acceleration: int):
"""Firmware command for starting shaking."""
speed_str = str(speed).zfill(4)
acceleration_str = str(acceleration).zfill(5)
return self._send_command("SB", st=direction, sv=speed_str, sr=acceleration_str)

async def _stop_shaking(self):
"""Firmware command for stopping shaking."""
return self._send_command("SC")

async def _wait_for_stop(self):
"""Firmware command for waiting for shaking to stop."""
return self._send_command("SW")

async def set_temperature(self, temperature: float):
"""set temperature in Celsius"""
temp_str = f"{round(10*temperature):04d}"
return self._send_command("TA", ta=temp_str)

async def get_current_temperature(self) -> float:
"""get temperature in Celsius"""
response = self._send_command("RT").decode("ascii")
temp = str(response).split(" ")[1].strip("+")
return float(temp) / 10

async def deactivate(self):
"""turn off heating"""
return self._send_command("TO")
return HeaterShaker(
name=name,
size_x=146.2,
size_y=103.8,
size_z=74.11,
backend=HamiltonHeaterShakerBackend(shaker_index=shaker_index),
child_location=Coordinate(x=9.66, y=9.22, z=74.11),
Copy link
Contributor

Choose a reason for hiding this comment

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

How have you made these measurements?

I've got child_location=Coordinate(x=9.25, y=8.5, z=76.1),

I'll check them right now again, my approach:

  • 3D printed a perfectly cuboidal SLAS-compliant "calibration plate block"
  • open HHS
  • place "calibration plate block" onto HHS
  • make HHS close + shake for 5 seconds + open again ->final position of "calibration plate block" is the real origin of the child location (in x and y dimension)

Copy link
Member Author

Choose a reason for hiding this comment

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

caliper and then iswap movements 🤡

how do you get the final position?

Copy link
Contributor

@ben-ray ben-ray Mar 18, 2025

Choose a reason for hiding this comment

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

the child_location discrepancy might be related to the orbit diameter, this was calibrated with a 3mm orbit shaker

Copy link
Contributor

Choose a reason for hiding this comment

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

Ours is also a 3mm orbit shaker.

how do you get the final position?

Explained here:

my approach:

  • 3D printed a perfectly cuboidal SLAS-compliant "calibration plate block"
  • open HHS
  • place "calibration plate block" onto HHS
  • make HHS close + shake for 5 seconds + open again ->final position of "calibration plate block" is the real origin of the child location (in x and y dimension)

Then using a teaching needle to move to the origin x-y coordinate of the "calibration plate block".
I have done this 1 year ago though and been using it since then.
I will remeasure tomorrow :)

)
126 changes: 126 additions & 0 deletions pylabrobot/heating_shaking/hamilton_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from enum import Enum
from typing import Literal

from pylabrobot.heating_shaking.backend import HeaterShakerBackend
from pylabrobot.io.usb import USB


class PlateLockPosition(Enum):
LOCKED = 1
UNLOCKED = 0


class HamiltonHeaterShakerBackend(HeaterShakerBackend):
"""
Backend for Hamilton Heater Shaker devices connected through an Heater Shaker Box
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this also say that 2 Hamilton_heater_shakers can be used via directly connecting them physically to the RS232 ports and controlling them via the STAR() backend?

But in that case this resource definition would still work as a standalone resource as long as the .setup() method is not called on it.

Copy link
Member Author

Choose a reason for hiding this comment

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

yes, that is possible. we should mention it

we haven't been able to figure out how that works though. one time when i tried i had to factory reset all software on the machine. I think if people start doing that, we would still have to have some way for users to control the machine (eg set_temperature) which is not possible without the appropriate backend. i suggest making this decision later when we actually figure out how to connect an HHS to the star directly.

Copy link
Contributor

@BioCam BioCam Mar 18, 2025

Choose a reason for hiding this comment

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

I've been using our HHSs with direct connection to the STAR since I integrated it in PR#66: Direct device integration: Hamilton Heater-Shaker (HHS) & Hamilton Heater-Cooler (HHC) - Part 1: device control

Should we then figure this out now?

Copy link
Member Author

Choose a reason for hiding this comment

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

i think cleaning that up can be a follow-up PR. Let's keep this one for renaming the backend and introducing a resource function.

"""

def __init__(
self,
shaker_index: int,
id_vendor: int = 0x8AF,
id_product: int = 0x8002,
) -> None:
"""
Multiple Hamilton Heater Shakers can be connected to the same Heat Shaker Box. Each has A
separate 'shaker index'
"""
assert shaker_index >= 0, "Shaker index must be non-negative"
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a limit to how many shakers can be connected to one HeaterShakerBox connection?

If yes, should this assertion ensure this is adhered to?

Copy link
Contributor

Choose a reason for hiding this comment

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

good point! there are only 8 power/data plugs on the box, but the hhs themselves can be set to any index between 0 and 9. index 0 is the one that gets a direct USB connection

self.shaker_index = shaker_index
self.command_id = 0

super().__init__()
self.io = USB(id_vendor=id_vendor, id_product=id_product)

async def setup(self):
"""
If io.setup() fails, ensure that libusb drivers were installed for the HHS as per docs.
"""
await self.io.setup()
await self._initialize_lock()

async def stop(self):
await self.io.stop()

def serialize(self) -> dict:
usb_serialized = self.io.serialize()
heater_shaker_serialized = HeaterShakerBackend.serialize(self)
return {
**usb_serialized,
**heater_shaker_serialized,
"shaker_index": self.shaker_index,
}

def _send_command(self, command: str, **kwargs):
assert len(command) == 2, "Command must be 2 characters long"
args = "".join([f"{key}{value}" for key, value in kwargs.items()])
self.io.write(f"T{self.shaker_index}{command}id{str(self.command_id).zfill(4)}{args}".encode())

self.command_id = (self.command_id + 1) % 10_000
return self.io.read()

async def shake(
self,
speed: float = 800,
direction: Literal[0, 1] = 0,
acceleration: int = 1_000,
):
"""
speed: steps per second
direction: 0 for positive, 1 for negative
acceleration: increments per second
"""
int_speed = int(speed)
assert 20 <= int_speed <= 2_000, "Speed must be between 20 and 2_000"
Copy link
Contributor

Choose a reason for hiding this comment

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

I do not think this is universally correct:

image

I believe we can disregard the adapters that can be attached to the top of the Hamilton_heater_shaker for the sake of machine control.

But we should distinguish between physical differences: It seems to me that actually there are 3 different Hamilton_heater_shaker models, classified based on their shaking_orbit_distance:

  • 1.5 mm
  • 2.0 mm
  • 3.0 mm

What is unclear is whether this actually changes the max RPM that these machines can achieve?

Having different adapters change the max safe RPM makes sense, but physically it shouldn't limit each Hamilton_heater_shakers capabilities... I think we should discuss how to implement this efficiently here

Copy link
Member Author

Choose a reason for hiding this comment

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

ohh this is very interesting. We actually arbitrarily take off adapters (just allen wrench) and put them on other heater shakers 😆

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should not restrain the shaker below the RPM that it can actually reach: the highest RPM from this list is 2500 RPM.

We can place these recommendations into the docstring:

v1 proposal:

def Hamilton_HS(name: str) -> PlateHolder:
  """
  Hamilton cat. no.: 199033, 199034, 199034
    - manufacturer_link: 
    - temperature control = RT+5°C - 105°C (all variants),
    - notes:
        -  Hamilton Heater Shaker, aka HHS, with flat bottom adapter,

   - variants:
        - 199027:
          - shaking orbit = 1.5mm,
          - shaking speed = 100 -1800 rpm,
        - 199033:
          - shaking orbit = 2.0mm,
          - shaking speed = 100 -2500 rpm,
        - 199034:
          - shaking orbit = 3.0mm,
          - shaking speed = 100 -2400 rpm,
          - max. loading = 500 grams
  """
  return PlateHolder(
    name=name,
    size_x=145.5,
    size_y=104.0,
    size_z=184.1-8.0-100, # includes HHS' carrier_site_skirt_height=2.85mm
    # probe height - carrier_height - deck_height
    child_location=Coordinate(9.25, 8.5, 184.1-8.0-100),
    model="Hamilton_HS",
    pedestal_size_z=0
    )

assert direction in [0, 1], "Direction must be 0 or 1"
assert 500 <= acceleration <= 10_000, "Acceleration must be between 500 and 10_000"

await self._start_shaking(direction=direction, speed=int_speed, acceleration=acceleration)

async def stop_shaking(self):
"""Shaker `stop_shaking` implementation."""
await self._stop_shaking()
await self._wait_for_stop()

async def _move_plate_lock(self, position: PlateLockPosition):
return self._send_command("LP", lp=position.value)

async def lock_plate(self):
await self._move_plate_lock(PlateLockPosition.LOCKED)

async def unlock_plate(self):
await self._move_plate_lock(PlateLockPosition.UNLOCKED)

async def _initialize_lock(self):
"""Firmware command initialize lock."""
result = self._send_command("LI")
return result

async def _start_shaking(self, direction: int, speed: int, acceleration: int):
"""Firmware command for starting shaking."""
speed_str = str(speed).zfill(4)
acceleration_str = str(acceleration).zfill(5)
return self._send_command("SB", st=direction, sv=speed_str, sr=acceleration_str)

async def _stop_shaking(self):
"""Firmware command for stopping shaking."""
return self._send_command("SC")

async def _wait_for_stop(self):
"""Firmware command for waiting for shaking to stop."""
return self._send_command("SW")

async def set_temperature(self, temperature: float):
"""set temperature in Celsius"""
temp_str = f"{round(10*temperature):04d}"
return self._send_command("TA", ta=temp_str)

async def get_current_temperature(self) -> float:
Copy link
Contributor

Choose a reason for hiding this comment

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

The Hamilton_heater_shaker has two temperature sensors.

Both are available via STAR backend control:
image

Are both sensors also available with "Heater Shaker Box" based USB control?
If yes/no, which sensor's reading is being return with this method?

Copy link
Member Author

Choose a reason for hiding this comment

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

oh nice, they are also available on the direct interface. the firmware api appears to be exactly the same

"""get temperature in Celsius"""
response = self._send_command("RT").decode("ascii")
temp = str(response).split(" ")[1].strip("+")
return float(temp) / 10
Copy link
Contributor

Choose a reason for hiding this comment

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

So this should be updated to return both values?

It looks like it only returns the edge temperature at the moment?


async def deactivate(self):
"""turn off heating"""
return self._send_command("TO")