Updating PLR API for machine interfaces discussion

I will comment on the high level api design first, and then respond to the “shaking is a part of reading” (for cytation + clariostar)

the adapter would be a way to split up backends, but proposal 2 is first and foremost about splitting what are currently the frontends:

this snippet is close to proposal 2, but here the capabilities are machine specific. We actually want to make them universal objects like Photometer/PlateReader and Imager (so we can share code and have a universal interface). In the code snippet above, CytationPhotometryAdapter is a specific to a machine which makes writing universal code difficult. Also it does not allow share logic between machines.

There are two ways of implementing proposal 2. I will clarify/summarize them below because the thread above is a little vague.

option 2a: with adapters

class Cytation10:
    def __init__(self, photometry = True, widefield = True, confocal = True, shaking = True, gas = False) -> None:
        self.backend = CytationBackend()
        if self.photometry:
            self.pr = PlateReader(backend=CytationPhotometryAdapter(cytation=self.backend))
        ...

where self.pr is a universal PlateReader, keeping its backend small and atomic, and providing universal logic shared across all plate readers. (For a plate reader that logic is pretty thin, but for arms for example we have to do resource model → coordinate conversions and for liquid handlers there are many state updates, which not every backend should have to reimplement.)

With adapters, CytationPhotometryAdapter implements PlateReaderBackend and uses the cytation as the interface /“back-backend”.

Going into more detail: the CytationPhotometryAdapter class pseudocode you gave includes

class CytationPhotometryAdapter(PlateReaderBackend):
    ...

    async def read_absorbance(self, wavelength: int, **kwargs):
        return await self._cytation.read_absorbance(
            wavelength=wavelength, **kwargs
        )

Here, CytationBackend.read_absorbance is a still a method and the adapter only forwards the call. This is not strictly necessary. It would make sense for the CytationPhotometryAdapter to implement the method directly, like the adapter defines the firmware command and only calls self._cytation.send_command (this nicely splits up backends.)

(see the similarity between proposal 2 splitting ‘current front ends’ and adapters being the analogous way of splitting ‘current back ends’ :))

option 2b: just with backends

there is a simpler case, when the backend can inherit from all classes at once:

class CytationBackend(PlateReaderBackend, ...):
  ...

class Cytation10:
    def __init__(self, photometry = True, widefield = True, confocal = True, shaking = True, gas = False) -> None:
        self.backend = CytationBackend()
        if self.photometry:
            self.pr = PlateReader(backend=self.backend)
        ...

here, CytationBackend implements PlateReaderBackend itself.

that would make sense when CytationBackend has direct methods for reading absorbance and such.


In both 2a and 2b:

  • Cytation10 is the class the user instantiates to work with that machine.
  • Cytation10.pr is a universal PlateReader. PlateReader takes any backend conforming to PlateReaderBackend
  • The Cytation10 class is responsible for instantiating the Cytation10Backend as well as the “front ends” PlateReader, Shaker, etc.
  • Reading would be like cytation10.pr.read_absorbance(...), shaking would be cytation10.shaker.shake (note: no cytation10.read_absorbance)

To the user using the Cytation10 interface this would look identical regardless of how the backends work. But using and writing backend interfaces would be slightly different, since under 2a Cytation10Backend might only have send_command and a few other methods with CytationPhotometryAdapter implementing the methods for actually reading plates. With 2b, it would be a big class implementing everything and potentially raising NotImplementedError for specific features.

In the new PLR, I think we can have a mix of a) adapters implementing abstract backends and b) machine backends directly implementing abstract backends, although there is something to be said for making the architecture uniform even for simple machines. The uniform architecture would mean (b) every machine has a dedicated adapter helper class to confirm to a particular backend spec (like PlateReaderBackend). This makes the code more consistent, but also more complex for simple machines.

Where adapters are truly needed is when a backend has multiple instances of something, like multiple arms. When I introduced the adapter pattern in my post above, it was so that that adapter can have information about which arm on the backend to actually use:

class EVOArmAdapter(ArmBackend):
  def __init__(self, evo: EVOBackend, arm_id: int):
    self._evo = evo
    self._arm_id = arm_id  # ←

  async def move_plate(self, source, target, **kwargs):
    return await self._evo.move_plate(
      source, target,
      arm_id=self._arm_id,  # ← 
      **kwargs
    )

with that snippet, EVOArmAdapter conforms to ArmBackend (having move_plate), but EVOBackend does not conform to ArmBackend because its EVOBackend.move_plate requires the arm_id parameter.

Reiterating other benefits: adapters also help break up code more nicely for complex machines, such as STARs (the backend is like 10k lines right now…) And yes in the case of a cytation, it doesn’t really make sense for the shared CytationBackend to talk about confocal things and we would rather put that in a separate class and have the Cytation10 class orchestrate things.

TLDR; adapters are necessary sometimes. We can make every current-backend work through adapters for consistency, at the cost of added complexity for simple machines.


shaking while reading: if shaking is truly not a standalone function, then yes it will have to be a backend kwarg for the read method.

But it is a good example of why capability boundaries need to be defined carefully, they should reflect how the instrument is actually used, not just what firmware commands exist.

I disagree: we should model the firmware at the most granular level possible as you can never be sure what users will want to do. What the machine supports, PLR should support. This does not mean we can’t provide utilities and convenience to make it nicer for “normal use cases” (in fact, that’s the role of front ends), but I do think we should split up backends by machine capability rather than “use case”. The goal of front ends IS to abstract use cases.