Updating PLR API for machine interfaces discussion

Building on the Option 2 and the great discussion so far, I want to add four connected points. I put this together by studying how other instrument control frameworks handle similar problems, Bluesky, QMI, EPICS, QCoDeS, MicroManager, ScopeFoundry, ACQ4, labscript suite and others, and with my llm to synthesize things quicker.

Apologies if this is too long or if some of it might be out of scope, but happy to discuss any of it. The four points:

  • a concurrency concern that the new architecture introduces (maybe allready raised here)
  • a proposal for how drivers declare capabilities via duck typing (similar to what Keoni mentioned in his post here)
  • a proposal for how drivers declare observable state via signals (related to the duck typing)
  • a folder structure that follows from the capabilities architecture.

1. A concurrency note for shared-wire capabilities

Option 2 gives STAR two capabilities on one driver:

class STAR(ResourceHolder, Machine):
    def __init__(self):
        driver = STARDriver()
        self.liquid_handling = LiquidHandlingCapability(driver=driver, device=self)
        self.arm = ArmCapability(driver=driver, device=self)

Both capabilities share one USB wire. If two async tasks call star.liquid_handling.aspirate() and star.arm.move_plate() simultaneously, commands collide on the wire. I think we need a lock at the driver level, one transaction at a time:

class STARConnection:
    def __init__(self):
        self._usb = USB(id_vendor=0x08AF, id_product=0x8000)
        self.lock = asyncio.Lock()

    async def transact(self, cmd: bytes) -> bytes:
        async with self.lock:
            await self._usb.write(cmd)
            return await self._usb.read()

Both capabilities route through STARConnection.transact(). The lock is invisible to capabilities — they just call driver methods. This is the same pattern QMI uses for shared instrument connections.

For the Cytation the situation is different: one driver but two independent physical connections (FTDI for plate reading, Spinnaker for imaging). No lock needed there, the connections don’t share a wire, and sequencing is enforced by await in protocol code. Also, the capabilties are not yet separated anyway, but for the future that might be needed a plate_reading (or motion) and and imaging capability.

2. Duck typing for drivers — Protocols at the driver boundary

Keoni raised something interesting in post #5:

Rick’s response was that LiquidHandler is more than a spec — it provides shared logic like validation and volume tracking that can’t live in a pure Protocol. That’s correct. But I think duck typing belongs one level lower: at the driver, not at the capability.

The problem with ABC at the driver level:

class ByonoyAbsorbance96Driver(PlateReaderBackend):  # forced to inherit
    async def read_fluorescence(self, ...):
        raise NotImplementedError   # silent lie — hardware can't do this

The type checker sees no problem. The user hits a runtime error deep inside a protocol run.

The proposal: drivers declare what they can do, nothing more.

Protocols replace ABC at the driver boundary:

# capabilities/photometry/protocols.py
@runtime_checkable
class CanReadAbsorbance(Protocol):
    async def read_absorbance(self, plate, wavelength: int) -> list[list[float]]: ...

@runtime_checkable
class CanReadFluorescence(Protocol):
    async def read_fluorescence(self, plate, excitation_wavelength: int,
                                emission_wavelength: int, focal_height: float) -> list[list[float]]: ...

Drivers implement only what the hardware supports, no inheritance, no NotImplementedError:

# devices/byonoy/absorbance96/driver.py
class ByonoyDriver:                          # no PlateReaderBackend parent
    absorbance = SignalR(unit="OD")          # honest declaration

    async def read_absorbance(self, plate, wavelength): ...
    # read_fluorescence simply does not exist

The capability checks at call time:

await byonoy.plate_reading.read_fluorescence(...)
# → AttributeError: 'ByonoyDriver' does not support 'fluorescence'
#   Available signals: ['absorbance']

Instead of a silent NotImplementedError, a clear message before it ever reaches the driver.

We are currently working on a driver for the NanoDrop — and this is exactly the device that prompted thinking about this architecture. The NanoDrop measures absorbance on single samples, not plates. It is not a plate reader, but it shares the photometry capability with CLARIOstar and Byonoy. Forcing it into PlateReaderBackend would be the wrong model. With duck typing it simply implements CanReadAbsorbance and gets the photometry capability directly — no forced inheritance, no wrong category.

The capability layer (with its shared logic, validation, volume tracking) stays exactly as Rick designed it. Duck typing only replaces the ABC at the driver boundary.

3. Signals — alongside Protocols

Bluesky introduced the idea that instruments don’t just execute commands, they also continuously expose readable state: a temperature, a pressure, a busy flag, a last measurement value. Bluesky calls these “readings” from a Readable device. The same concept appears in TANGO (Attributes), EPICS (Process Variables), and ophyd-async (SignalR/SignalRW/SignalX). This is useful beyond just driving hardware, it is the foundation for telemetry, monitoring, live UI, and background data acquisition.

I’d like to propose we adopt the same pattern in PLR. Next to Protocols (for commands), drivers also declare Signals, observable device state:

class SignalR:   # read-only measurand: absorbance, temperature
class SignalRW:  # read-write parameter: temperature setpoint
class SignalX:   # trigger: open_drawer, start_shaking
class CLARIOstarDriver:
    absorbance   = SignalR(unit="OD",  description="Absorbance")
    fluorescence = SignalR(unit="RFU", description="Fluorescence intensity")
    luminescence = SignalR(unit="RLU", description="Luminescence intensity")
    temperature  = SignalR(unit="°C",  description="Plate temperature")
class ByonoyDriver:
    absorbance = SignalR(unit="OD")
    # fluorescence not declared — not a lie, just absence

Signals and Protocols are complementary. Both are declaration-based, not inheritance-based. Together they replace the ABC at the driver boundary, the capability layer itself can keep whatever structure makes sense.

4. Folder structure

the big change will be that the frontend does not live in the same folder as the backend anymore for the machines. Instead the device lives there. and the capabilties is a new folder, that was previsouly the frontend.

pylabrobot/
│
├── io/                           ← generic transports (unchanged)
│     usb.py
│     ftdi.py
│     serial.py
│     hid.py
│     socket.py
│
├── resources/                    ← digital twin (unchanged)
│
├── machines/                     ← Machine class (unchanged)
│     machine.py
│
├── capabilities/
│     ├── photometry/             ← ATOMIC
│     │     protocols.py          ← CanReadAbsorbance, CanReadFluorescence
│     │     signals.py            ← SignalR definitions
│     │     capability.py         ← PhotometryCapability
│     ├── imaging/                ← ATOMIC
│     │     protocols.py
│     │     signals.py
│     │     capability.py
│     ├── motion/                 ← ATOMIC
│     │     protocols.py
│     │     signals.py
│     │     capability.py
│     ├── liquid_handling/        ← ATOMIC (existing)
│     │     protocols.py
│     │     signals.py
│     │     capability.py
│     └── plate_reading/          ← COMPOSITE (existing, unchanged)
│           protocols.py
│           capability.py
│
└── devices/
      ├── __init__.py             ← re-exports all; users never see vendor paths
      ├── hamilton/
      │     └── star/
      │           device.py       ← STAR(ResourceHolder, Machine)
      │           driver.py
      │           connection.py   ← STARConnection (USB + asyncio.Lock)
      ├── bmg/
      │     └── clariostar/
      │           device.py       ← CLARIOstar(ResourceHolder, Machine)
      │           driver.py
      ├── biotek/
      │     └── cytation/
      │           device.py       ← Cytation5(ResourceHolder, Machine)
      │           driver.py       ← owns FTDI + Spinnaker connections internally
      └── thermo/
            └── nanodrop/
                  device.py       ← NanoDrop(ResourceHolder, Machine)
                  driver.py       ← uses photometry/ directly, not plate_reading/

plate_reading/ stays exactly as it is, for now. no breaking change, but could later become a combination of photometry/ and motion/(the composite). photometry/ is new and is where a new device like a NanoDrop could land.

1 Like