Updating PLR API for machine interfaces discussion

Yes i see your point. It might as well be ABC instead of protocol. protocol without inheritance might be good for third party drivers. At first glance the duck typing sounded attractive, but making the capabilities more granular solves the problem, not the duck typing.

i feel devices earn a separate folder, although the device is also a resource, it is a special resource with capabiltiies, that the labware do not have.

Yes agree. My original reason for keeping plate_reading/ was avoiding a breaking change, not architecture. If you are comfortable with the breaking change, dissolving it into photometry + stage at the device layer is the cleaner design.

1 Like

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)

draft PR: Capability composition architecture by rickwierenga ¡ Pull Request #931 ¡ PyLabRobot/pylabrobot ¡ GitHub

big todos:

  • renaming things, like backend → driver, machine → device etc.
  • plate readers
  • liquid handlers
  • arms

but the general pattern for

  • legacy module for compatibility
  • vendor modules
  • capabilities

are there

2 Likes

@rickwierenga agree with the structure and the argument for separate capability instances over kwargs.

One naming proposal: the abstract ABCs that define what a backend must implement. Why not call those contracts, named with a Can... prefix.

Why?: Backend currently means both the abstract spec (OrientableArmCapabilityBackend) and the concrete implementation (STARiSWAPBackend). The Can... prefix disambiguates at a glance.

Arm example (see recent arm thread):

  # Contracts — the spec
  class CanPickUpResource(DeviceBackend, ABC):
      async def pick_up_resource(self, ...) -> None: ...
      async def drop_resource(self, ...) -> None: ...

  class CanOrientGrip(ABC):
      async def oriented_pick_up_resource(self, ..., grip_direction) -> None: ...

  # Drivers — the implementation (pick which contracts they fulfill)
  class STARiSWAPDriver(CanPickUpResource, CanOrientGrip): ...   # can rotate
  class STARCoreGripperDriver(CanPickUpResource): ...             # cannot rotate

The alternative would be keeping Backend for the ABC and using Driver for the concrete implementation, but that’s confusing, because in old PLR Backend meant the concrete implementation, not the spec.

Liquid handling example, why this matters for the Echo (see thread Echo 650 acoustic dispenser — interest in adding support?)

The current LiquidHandlerBackend as many abstract methods. The Echo 650 (acoustic dispenser) dispenses, no tips, no aspiration. With contracts:

  class EchoDriver(CanDispense): ...                              # just dispense
  class STARLHDriver(CanAspirate, CanDispense, CanPickUpTips,     # everything
                     CanDropTips, CanAspirate96, ...): ...

The Echo-specific operations like survey() can be a separate contract (CanSurveyPlate) if other acoustic dispensers also do this.

Finally, I prefer driver over backend, but that has been said before in this thread, also by @CamilloMoschner I think. So:

Layer What it is Example
Capability User-facing frontend ArmCapability, LiquidHandlingCapability
Contract Abstract ABC; the spec CanPickUpResource, CanDispense
Driver Concrete implementation of contracts STARiSWAPDriver, STARDriver, EchoDriver
IO Machine connection STARConnection, Serial, USB

Just some ideas, other naming choices could work just as well.

1 Like

call those contracts

I like the Can naming.

If the “contracts” are named CanX then it is slightly confusing to me how to combine that with Capabilities being their front end. Capability sounds like a “can x” but in this case the capability has the “can x” as backends.

abc

ABCs for contracts/“capability-backends” are just arbitrary, they provide the same as typing.Protocol would afaik.

CanPickUpResource(DeviceBackend

Not sure CanPickUpResource(DeviceBackend is actually intended to inherit from DeviceBackend, but in any case I don’t think it should since sometimes we might want to have helper classes like for the core grippers to implement those rather than the actual driver class directly. Specifically in the case of transient capabilities (like core grippers) and duplicated (like multiple arms on evo)

STARiSWAPDriver(CanPickUpResource, CanOrientGrip):

This is slightly problematic in the sense that CanPickUpResource will not have a direction parameter for the pickup, whereas for the iswap and other CanOrientGrip objects it is actually required to specify it. So CanPickUpResource and CanOrientGrip are in a way disjoint, even though CanOrientGrip should inherit some logic from CanPickUpResource (placement calculation)

CanAspirate, CanDispense, CanPickUpTips, CanDropTips

splitting this is a very interesting idea

it does lead to the question of how this would be represented on the “resource front end” ie STAR: it would no longer be star.lh.dispense but like star.dispense.dispense :face_with_raised_eyebrow: Or we would need a separate object called lh on the star that has the capability front end, but this gets complicated.

I am necessarily not opposed to splitting dispensing machines from universal liquid handlers, “a little duplication is cheaper than the wrong abstraction”, but I think there might be something here

2 Likes

AcousticDispenser afaik are dispensing a discrete number of 25nl or 50nl droplets.

Because Dispensers operate using a physically different operation from pipette tips, we want users to know the functionality of Dispenser is not hotswappable onto a LiquidHandler, and especially not AcousticDispenser.

As a standard, we should strive to make obvious what equipment users need to buy and/or implement to replicate open-source pylabrobot protocols locally.

1 Like

all good points,

Capability vs Contract naming You’re right that “capability” naturally means “can do X” which is what the contracts define. It’s a bit circular. But I think it works in practice: Can… names are the spec, …Capability is the user-facing frontend. The user writes star.iswap.move(), they rarely type ArmCapability directly.

ABC vs Protocol
Agreed, either works. ABCs make the inheritance explicit class HamiltonHSDriver(DeviceBackend, CanShake, CanLockPlate), you can read the class line to see what it satisfies), but Protocols would be fine too.

Contracts inheriting DeviceBackend

Transient capabilities like core grippers and duplicate arms on the EVO need to implement contracts without having their own lifecycle. If CanPickUpResource inherits DeviceBackend, those helper objects get forced into having setup()/stop() which makes no sense, they share the driver’s connection. Your EVOArmAdapter is a good example, it satisfies the arm contract but doesn’t own a connection, it just holds a reference to the real driver and injects arm_id per call. Same pattern for core grippers. These adapters shouldn’t need DeviceBackend.

Cleaner approach: all contracts inherit ABC only. The concrete driver inherits DeviceBackend separately:

  # Driver — owns the connection, has lifecycle                                                                                                                            
  class BioShakeDriver(DeviceBackend, CanShake, CanLockPlate, CanSetTemperature):
      ...                                                                                                                                                                  
                                                                                                                                                                         
  # Driver with arm contracts — also owns lifecycle
  class STARiSWAPDriver(DeviceBackend, CanPickUpResource, CanOrientGrip):
      ...

  # Adapter — no lifecycle, shares the driver's connection.
  # Useful for machines with multiple instances of the same capability
  # (e.g. EVO with two RoMa arms, or transient core grippers on STAR)
  class EVOArmAdapter(CanPickUpResource):
      def __init__(self, driver: EVODriver, arm_id: int):
          self._driver = driver
          self._arm_id = arm_id

      async def pick_up_resource(self, ...):
          await self._driver.pick_up_resource(..., arm_id=self._arm_id)

  class CoreGripperAdapter(CanPickUpResource):
      def __init__(self, driver: STARDriver, channels: list[int]):
          self._driver = driver
          self._channels = channels

  # Device wiring — each adapter becomes its own capability instance
  class EVO(Resource, Device):
      def __init__(self):
          self._driver = EVODriver()
          self.arm1 = ArmCapability(backend=EVOArmAdapter(self._driver, arm_id=1))
          self.arm2 = ArmCapability(backend=EVOArmAdapter(self._driver, arm_id=2))

But this might fall apart for the CoRe gripper (see next point). It works as long as contracts are truly additive (base + supplement), which they might not be for arms.

CanPickUpResource and CanOrientGrip being disjoint

Also a good point. For the iSwap, direction is required, not optional. So these aren’t “basic + add-on”, they’re genuinely different interfaces, like you proposed in the arm thread. Maybe CanPickUpResource is for simple arms and CanOrientedPickUp is separate and standalone. Or maybe CanNonOrientedPickUp and CanOrientedPickUp, to circumvent that the least capable gets the highest physical meaning, but defining something by what it can’t do gets ugly fast.

This is the same kind of problem as plate reading: CanReadAbsorbance currently takes plate: Plate, wells: list[Well], but a cuvette reader has no wells and a pedestal reader reads a single droplet. Options I see:

  1. one contract with a union type that grows with every geometry
  2. separate contracts per mechanism (CanReadPlateAbsorbance, CanReadPedestalAbsorbance) which multiplies frontends but maybe the right solution, just like the Arm, OrientedArm, JointedArm
  3. abstract over the geometry with something like SamplePosition that wells, cuvettes, and pedestals all satisfy.

star.lh.dispense

I think star.lh as a single LiquidHandlingCapability is the right answer, one frontend that uses isinstance to check what the driver supports. Same pattern as star.iswap being one ArmCapability. No star.dispense.dispense(). I thought that was already settled.

On acoustic dispensers

That is a fair point. Echo dispensing (25 nL acoustic droplets) and STAR dispensing (tip-based, 1–1000 uL) are physically very different operations, and protocols should make clear what equipment you need. Where exactly to draw the contract/capability boundaries for liquid handling, whether an Echo shares CanDispense with tip-based handlers or gets its own CanAcousticDispense, is a taxonomy question. And taxonomy is hard: a pump is also a liquid handler, so is a microfluidic device pulling liquid with capillary forces, even condensation or evaporation is liquid handling. The contract pattern supports all these choices. A separate capabilities class CanAcousticDispense is also fine off course.

1 Like

well it would be the type of the parameter in a function that requires such a capability.

also for developers it’s also confusing

I am starting to lean more towards Capability and CapabilityBackend, such as LiquidHandler or CartesianArm and SCARA for capabilities, with CartesianArmBackend etc. as backends for them. That is clean.

clean

defining something by what it can’t do gets ugly fast.

it’s just a ‘type of pickup’ in this case

good point, it occurs in more places than just arms. Want to start a new thread on the plate reading standard though? I want to keep this one focused on general architecture.

Yes, but if we are gonna split dispense into a separately capability, it would also need a separate interface… However after Ben’s comment I am not sure this is a good abstraction (even though both dispense liquid, a little duplication is better than the wrong abstraction. and we can of course still share the volume tracker etc. between functions.)

1 Like

since you referred to this thread, i might as well reply here.

I think this Infinite example is confusing.

We can easily split concrete backends, but you are making abstract backends for specific devices. Can you clarify?

thanks for bringing it here

the backends here are concrete in the code example though? InfiniteAbsorbance(AbsorbanceReaderBackend), etc

the capabilities AbsorbanceReader et al are generic yes, but they are front ends not backends. the backends are passed to them through backend=.

hope this clarifies, but I don’t think I fully understand your question so please let me know what I’m missing

Apologies, I misread your example. I see it now: InfiniteAbsorbance is the concrete backend with the firmware commands, TecanInfiniteDriver is the shared IO connection underneath. Same universal ABCs, just the concrete layer split per capability for larger backends. That makes sense for big drivers.

To make sure I follow the layers correctly, here’s how it looks today on the capability branch (Cytation 1):

class Cytation1(Resource, Device):                                                                                                                                   
     def __init__(self, ...):
         backend = BioTekBackend(device_id=device_id)                                                                                                                 
         Device.__init__(self, backend=backend)          
         self.absorbance = AbsorbanceCapability(backend=backend)                                                                                                      
         self.luminescence = LuminescenceCapability(backend=backend)                                                                                                  
         self.fluorescence = FluorescenceCapability(backend=backend) 

One backend, all capabilities share it. And your new example for larger backends:

class Infinite:                                                                                                                                                      
    def __init__(self, ...):                            
      self.driver = InfiniteDriver(...)                                                                                                                            
        self.absorbance_reader = AbsorbanceReader(                                                                                                                   
            backend=InfiniteAbsorbance(driver=self.driver))                                                                                                          
        self.temperature_controller = TemperatureController(                                                                                                         
            backend=InfiniteTemperatureController(driver=self.driver))                                                                                               
        self.shaker = Shaker(                                                                                                                                        
            backend=InfiniteShaker(driver=self.driver))                                                                             

Same architecture, concrete backends split per capability. Both work.

One thing I notice: the naming is different between the two examples, that’s likely where my confusion also came from. On the branch it’s AbsorbanceCapability, in the new example it’s AbsorbanceReader.

Would more consistent naming help readability? Something like:

# Simple (small backend, Cytation pattern)
  class Cytation1(Resource, Device):
      def __init__(self, ...):
          backend = CytationDriver(device_id=device_id)
          Device.__init__(self, backend=backend)
          self.absorbance = AbsorbanceCapability(backend=backend)
          self.luminescence = LuminescenceCapability(backend=backend)
          self.fluorescence = FluorescenceCapability(backend=backend)
          self._capabilities = [self.absorbance, self.luminescence, self.fluorescence]

  # Split (large backend, Infinite pattern)
  class Infinite(Resource, Device):
      def __init__(self, ...):
          driver = InfiniteDriver(...)
          Device.__init__(self, backend=driver)
          self.absorbance = AbsorbanceCapability(
              backend=InfiniteAbsorbanceDriver(driver=driver))
          self.temperature = TemperatureControlCapability(
              backend=InfiniteTemperatureDriver(driver=driver))
          self.shaker = ShakingCapability(
              backend=InfiniteShakerDriver(driver=driver))
          self._capabilities = [self.absorbance, self.temperature, self.shaker]

But since you seem to not have decided on the naming, let me propose another option:

  class Infinite(Resource, Device):
      def __init__(self, ...):
          connection = InfiniteConnection(...)           # shared IO (serial/USB)
          Device.__init__(self, backend=connection)
          self.absorbance = Absorbance(                  # frontend
              backend=InfiniteAbsorbanceDriver(           # concrete driver
                  connection=connection))
          self.temperature = Temperature(                # frontend
              backend=InfiniteTemperatureDriver(           # concrete driver
                  connection=connection))

And on the ABC naming: CanReadAbsorbance instead of AbsorbanceBackend reads better in the two places it actually shows up:

  # ABC — standalone, no DeviceBackend inheritance                                                                                                                       
  class CanReadAbsorbance(ABC):                                                                                                                                          
      @abstractmethod                                                                                                                                                    
      async def read_absorbance(self, wavelength: int, wells: list): ...  

  # Class definition — reads like a spec sheet
  class InfiniteAbsorbanceDriver(DeviceBackend, CanReadAbsorbance):
      ...

  # Type hint — says what the frontend needs
  class Absorbance:
      def __init__(self, backend: CanReadAbsorbance):
          ...

backend: CanReadAbsorbance says “give me anything that can read absorbance.” backend: AbsorbanceBackend says “give me an absorbance backend”, which is circular, the name just restates the type.

Note: CanReadAbsorbance inherits from ABC only, not from DeviceBackend. The capability interface stays separate from lifecycle management (setup/stop). They meet on the concrete driver: InfiniteAbsorbanceDriver(DeviceBackend, CanReadAbsorbance). This avoids the diamond where every backend ABC carries DeviceBackend in its chain.

I don’t know exactly if this would fit the LIFO build up or teardown, and lifecycle stuff.

exactly!

right, that’s something I/we are still deciding on :slight_smile:

I like both AbsorbanceCapability and AbsorbanceReader. Capability is more descriptive for the object name. For attributes, like Cytation1().<capability> I would say Cytation1().absorbance_reader is clearer than Cytation1().absorbance_capability. For user written protocols/steps (meaning functions) that use the capability, I would say def read_absorbance(plate, absorbance_reader: AbsorbanceReader) is clearer since it’s the action rather than capability. Just any absorbance reader, who cares that it’s called a capability abstractly?

Just Absorbance is a bit too vague in my opinion. Is it the reader, or a measurement, or a spec?

I like CanReadAbsorbance for backends, especially for mixin-patterns (like CanLock for shakers, CanFreedrive for arms). However, we will then run into the problem where the capability front end like AbsorbanceReader can read absorbance but it is actually different and dependent on a CanReadAbsorbance…

says “give me an absorbance backend”, which is circular, the name just restates the type.

so is naming the front end Capability. I think there’s some benefit to this idea because it clearly differentiates what’s what. Not required, but not necessarily bad. Also, backends/drivers will primarily live in the background since users will just instantiate the Device subclass such as Cytation1 and then access its capabilities.

1 Like

Thinking about your Tecan Infinite example and building on what you said earlier about adapters:

Agreed. And once the adapter defines the firmware commands directly using send_command, it’s a standalone per-capability driver. At that point, the abstract backend method merges into the capability. The separate backend ABC has nothing left to do.

Today AbsorbanceCapability is two lines of logic, then forwarding:

  class AbsorbanceCapability(Capability):
      @need_capability_ready
      async def read(self, plate, wavelength, wells=None):
          if wells is None:
              wells = plate.get_all_items()
          return await self.backend.read_absorbance(plate, wells, wavelength)

What if the abstract backend method lives on the capability instead?

  class AbsorbanceCapability(ABC):                                                                                                                                       
      @abstractmethod                                       
      async def _read_absorbance(self, plate, wells, wavelength): ...
                                                                                                                                                                         
      async def read(self, plate, wavelength, wells=None):
          if wells is None:                                                                                                                                              
              wells = plate.get_all_items()                 
          return await self._read_absorbance(plate, wells, wavelength)

The driver inherits from it directly:

 class CytationAbsorbanceDriver(AbsorbanceCapability):                                                                                                                  
   def __init__(self, connection):                                                                                                                                    
       self.connection = connection
   async def _read_absorbance(self, plate, wells, wavelength):                                                                                                        
       await self.connection.send_command("D", ...) 

The underscore on _read_absorbance marks it as internal, IDEs hide it from autocomplete, users only see read().

(Side note: CanReadAbsorbance instead of AbsorbanceCapability might also work as a name here)

class Cytation5(Resource, Device):                                                                                                                                     
    def __init__(self, ...):                              
        connection = BioTekConnection(device_id=device_id)                                                                                                             
        Device.__init__(self, backend=connection)
        self.absorbance: AbsorbanceCapability = CytationAbsorbanceDriver(connection)                                                                                   
        self.fluorescence: FluorescenceCapability = CytationFluorescenceDriver(connection)
        self.microscopy: MicroscopyCapability = CytationMicroscopyDriver(connection) 

User API unchanged: cytation.absorbance.read(plate, wavelength=450). Type hints show the capabilities. One class fewer per capability. The separate AbsorbanceBackendABC has nothing left to do.

Your EVOArmAdapter already has this shape, like you mentioned earlier:

A thin object holding a shared connection reference with an instance identity. Same shape as the split driver, it just inherits from the backend ABC instead of the capability. If it inherits from the capability instead, it gets the user API directly:


class EVOArmDriver(ArmCapability):
    def __init__(self, connection: EVOConnection, arm_id: int):
        self.connection = connection
        self.arm_id = arm_id

    async def _move_plate(self, source, target, **kwargs):
        return await self.connection.move_plate(source, target, arm_id=self.arm_id, **kwargs)

evo.arms = [EVOArmDriver(connection, arm_id=0), EVOArmDriver(connection, arm_id=1)]

this is essentially the question “why frontends/backends rather than inheritance?”

two reasons:

  1. while the logic for plate reading here is pretty thin (if wells is None: wells = plate.get_all_items()), it can be(come) more complex. For liquid handlers a lot of logic needs to be shared, and we don’t want to make every machine class responsible for that.
  2. when subclassing, subclasses can override methods. This will make it easy to break the standard or change behavior only on some machines. With front ends, this behavior is strictly controlled

(this is the same reasoning for having LiquidHandler/LiquidHandlerBackend in PLR since day 0, doesn’t change with the capability architecture)

To be clear: I’m not proposing to get rid of the frontend/backend split. The shared logic in self.pip.aspirate() stays on the capability, that IS the frontend.

What I’m thinking about is removing the thin abstract backend ABC in between. On the capability branch we have four layers:

Device → Capability → Backend ABC → Concrete Backend.

With the backends split per capability, the ABC often holds one abstract method. That can live on the capability itself as _read_absorbance(). The driver inherits the capability, implements the underscore method, and talks to the shared connection.

Still a frontend/backend split, just one less class with confusing naming, and one file less:

Device → Capability (frontend + contract) → Backend/Driver → Connection.

Where one backend covers multiple capabilities, like there are now, the separate abstract backends are still needed though, I agree on that.

oh yes, the concrete backend definitely subclasses the ABC. That’s how it currently works and how ABCs typically work. :sweat_smile:

The ABC is the “contract” part, it doesn’t really add code/implementation. We might make it a Protocol. It’s just the type for the backend attribute, and provides a base class for concrete backends to have type signatures.