Updating PLR API for machine interfaces discussion

responding to two points made by @vcjdeboer in the arm modeling thread

1. capability vs capability backend

But what you’re describing is more like the contract that defines what the driver has to implement right?

kind of repeating what’s in the long thread above, hopefully more clearly:
similar to current frontends/backends, we need two layers for capabilities: the frontend that shares code and utility functions, and then minimal backends, which are just a spec/protocol/currently implemented through ABCs with atomic commands. Each capability will need an associated backend for the machine specific commands. Further, each machine obviously needs to have one object to manage the machine connection which we will call the Driver.

So ultimately the capabilities need to call something on the driver. There are two ways for this:

  1. the driver implements the capability spec directly
  2. there are separate objects implementing the capability spec, that then call the driver

I imagine we will use both patterns depending on the nature of the machine. Some, like a heater shaker backend are clearly always both a shaker and temperature controller and can just implement the backend specs. In other cases, like the iswap on a hamilton is 1) an optional module, meaning it can not exist on some star backends and 2) the the star backend is already massive and will benefit from refactoring things into different objects.

(see original post in this thread: Updating PLR API for machine interfaces discussion - #24 by rickwierenga)

so to get back to the specific example:

I imagine we will have

# star/driver.py
class STARDriver:
  def send_command(self, command):
    ...

# star/iswap.py
class STARiSWAPBackend(OrientableArmCapabilityBackend):
  def __init__(self, driver: STARDriver):
    self._driver = driver

  def pick_up_resource(self, location: Coordinate, rotation: Rotation, ...):
    ...
    self._driver.send_command("C0PP")
    ...

# star.py: organize everything in the user facing model
class STAR(Device, Resource):
  def __init__(self) -> None:
    self._driver = STARDriver()
    self.iswap = OrientableArmCapability(backend=STARiSWAPBackend(driver=self._driver))

something similar for the core grippers, which leads us to …

2. separate capabilities for iswap and core grippers

if it’s just star.arm and then the user picks the mechanism through a kwarg like use_arm=“iswap”

first of all, the core grippers and iswap are not actually interchangeable because iswap has a concept of rotation whereas the core grippers do not. (see Modeling arm capabilities - #4 by rickwierenga)

second, let’s assume a particular machine has two equivalent arms. Using kwargs for specifying which arm to use does not scale nicely at all

from my very first proposal, regarding kwargs:

Where I see this running into problems is when a machine has multiple “machines” with one backend. For example, a Tecan EVO can have two independent gripper arms. The proposal for this is to use backend_kwargs:

# Evo inherits from Arm but has two arms
# use_arm is a backend_kwarg for EVOBackend.move_plate
read_plate_byonoy(evo, ..., use_arm=1)

This is extremely awkward since we don’t even know where to send the backend_kwarg in read_plate_byonoy. You’d have to have read_plate_byonoy (..., arm_kwargs: dict, reader_kwargs: dict).

it should be easy to just pass the capability to functions (protocols or parts of protocols) that require this capability. Functions are written to do things that require a certain capability, like they take LH or PR as arguments. With kwargs, you cannot nicely refer to a capability on a machine since it will require a kwarg to know which you are referring to. If you have different Capability instances, you can actually pass them directly, which is much nicer.

In the specific read_plate_byonoy example, it will take an arm parameter. Which type of arm depends on what is needed, if the resources being moved in the function do not need rotation it can be an Arm, otherwise you need at least a OrientableArm:

async def read_plate_byonoy(b: Byonoy, arm: Arm, plate: Plate...): ...

# or

async def read_plate_byonoy(b: Byonoy, arm: OrientableArm, plate: Plate...): ...

this is nice to use:

# iswap:
read_plate_byonoy(..., arm=star.iswap)

# core grippers (arm that exists transiently):
async with star.core_grippers(front_channel=6) as arm:
  read_plate_byonoy(arm=arm, ...)

(from Updating PLR API for machine interfaces discussion - #4 by rickwierenga)