Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ff6e7c7
Move device modules to pylabrobot/legacy/ and fix imports
rickwierenga Mar 10, 2026
b2cd0b5
Add Capability base class for machine capabilities
rickwierenga Mar 10, 2026
1024c1d
Add TemperatureControlCapability in pylabrobot.capabilities
rickwierenga Mar 10, 2026
f488bba
Add InhecoCPAC vendor module, make Machine.backend private, fix legac…
rickwierenga Mar 10, 2026
4904f56
Add InhecoThermoShake vendor module and ShakingCapability
rickwierenga Mar 10, 2026
ac1f8e8
Add BioShake vendor module with per-model classes and conditional cap…
rickwierenga Mar 10, 2026
cb57987
Add SealingCapability and Azenta A4S vendor module
rickwierenga Mar 10, 2026
3e4d36b
Move _capabilities lifecycle to Machine base class
rickwierenga Mar 10, 2026
752aed1
Add Hamilton Heater Shaker vendor module
rickwierenga Mar 10, 2026
0df1bde
Add Inheco SCILA vendor module with TemperatureControllerBackend
rickwierenga Mar 10, 2026
a312c11
Add WeighingCapability and Mettler Toledo vendor module
rickwierenga Mar 10, 2026
6516eca
Add AutomatedRetrieval and HumidityControl capabilities, Cytomat and …
rickwierenga Mar 10, 2026
ac255e7
Add BarcodeScanningCapability and model-driven Liconic capabilities
rickwierenga Mar 10, 2026
1e53336
Add TiltingCapability and Hamilton tilt module vendor module
rickwierenga Mar 10, 2026
1af2abe
Add PeelingCapability and Azenta XPeel vendor module
rickwierenga Mar 11, 2026
782f28f
Add FanControlCapability and Hamilton HEPA fan vendor module
rickwierenga Mar 11, 2026
86041bc
Introduce Device/DeviceBackend, retire pylabrobot/machines/
rickwierenga Mar 11, 2026
ea46179
Add MicroscopyCapability and Molecular Devices Pico vendor module
rickwierenga Mar 11, 2026
b74ebdf
Add Absorbance, Fluorescence, and Luminescence capabilities and Byono…
rickwierenga Mar 11, 2026
6c47cc5
Add CLARIOstar vendor module and migrate legacy backend
rickwierenga Mar 11, 2026
6e0d7dd
Add Agilent BioTek vendor module and migrate legacy backends
rickwierenga Mar 11, 2026
ccc73b9
Add SpectraMax vendor module and migrate legacy backends
rickwierenga Mar 11, 2026
226e6d4
add pico under imageXpress
rickwierenga Mar 11, 2026
48b0b29
Merge branch 'v1b1' into capability-architecture
rickwierenga Mar 23, 2026
32466b1
update pico path
rickwierenga Mar 23, 2026
76dca76
Fix all mypy type errors and establish adapter pattern for legacy wra…
rickwierenga Mar 23, 2026
a043db1
Rename microscopy extra to cytation-microscopy, add numpy to pico dir…
rickwierenga Mar 23, 2026
ebbb7fa
Add pytest.importorskip for numpy in microscopy tests
rickwierenga Mar 23, 2026
9985733
Fix time.time mock leak in biotek tests, update CI for cytation-micro…
rickwierenga Mar 23, 2026
3362967
Format and fix import ordering
rickwierenga Mar 23, 2026
8c08ddf
Format inheco_sila_interface.py
rickwierenga Mar 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest]
extra: ["<none>", serial, usb, ftdi, hid, modbus, opentrons, sila, microscopy, pico]
extra: ["<none>", serial, usb, ftdi, hid, modbus, opentrons, sila, cytation-microscopy, pico]

name: Tests (${{ matrix.extra }}, py3.12)
runs-on: ${{ matrix.os }}
Expand Down
233 changes: 233 additions & 0 deletions creating-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
# Creating capabilities

This document describes how to create new capabilities and how to migrate legacy
`Machine`/`MachineBackend` modules to the new `Device`/`Capability`/`DeviceBackend` architecture.

## Architecture overview

**Old (legacy):** A `Machine` owns a single `MachineBackend`. The frontend class contains all
the logic and calls backend methods directly.

```
Machine (frontend)
└── MachineBackend (abstract, one big interface)
└── ConcreteBackend (vendor implementation)
```

**New:** A `Device` owns a `DeviceBackend` and one or more `Capability` objects. Each capability
is a focused interface (e.g. shaking, temperature control) with its own backend type. Frontend
logic lives in the capability, not the device.

```
Device
├── ShakerBackend (DeviceBackend subclass)
├── ShakingCapability (owns a reference to the backend)
└── TemperatureControlCapability (owns a reference to the same backend)
```

### Key classes

| Class | Location | Role |
|-------|----------|------|
| `DeviceBackend` | `pylabrobot.device` | Base for all new backends. Abstract `setup()` and `stop()`. |
| `Device` | `pylabrobot.device` | Base for all new devices. Manages capabilities lifecycle. |
| `Capability` | `pylabrobot.capabilities.capability` | Base for capabilities. Owned by a `Device`. |
| `MachineBackend` | `pylabrobot.legacy.machines.backend` | Legacy backend base. Independent from `DeviceBackend`. |
| `Machine` | `pylabrobot.legacy.machines.machine` | Legacy frontend base. |

## Creating a new capability

### 1. Define the backend

Create an abstract backend in `pylabrobot/capabilities/<name>/backend.py`:

```python
from abc import ABCMeta, abstractmethod
from pylabrobot.device import DeviceBackend

class ShakerBackend(DeviceBackend, metaclass=ABCMeta):
@abstractmethod
async def start_shaking(self, speed: float): ...

@abstractmethod
async def stop_shaking(self): ...
```

The backend defines *what* operations are possible. Keep it minimal — one capability, one concern.

### 2. Define the capability

Create the capability in `pylabrobot/capabilities/<name>/<name>.py`:

```python
from pylabrobot.capabilities.capability import Capability
from .backend import ShakerBackend

class ShakingCapability(Capability):
def __init__(self, backend: ShakerBackend):
super().__init__(backend=backend)
self.backend: ShakerBackend = backend

async def shake(self, speed: float, duration: float = None):
await self.backend.start_shaking(speed=speed)
if duration:
await asyncio.sleep(duration)
await self.backend.stop_shaking()
```

Frontend logic (validation, orchestration, convenience methods) lives here, not in the backend.

### 3. Implement vendor backends

In `pylabrobot/<vendor>/`, create a concrete backend and device:

```python
from pylabrobot.capabilities.shaking import ShakerBackend, ShakingCapability
from pylabrobot.device import Device

class MyVendorShakerBackend(ShakerBackend):
async def setup(self): ...
async def stop(self): ...
async def start_shaking(self, speed: float): ...
async def stop_shaking(self): ...

class MyVendorShaker(Device):
def __init__(self, backend: MyVendorShakerBackend):
super().__init__(backend=backend)
self.shaking = ShakingCapability(backend=backend)
self._capabilities = [self.shaking]
```

## Making legacy code wrap new code

When a legacy module already exists, the goal is to move the *implementation* into capabilities
while keeping the legacy frontend and backend interfaces unchanged. Users of the old API should
not need to change anything.

### Principles

1. **Legacy types don't change.** The old `MachineBackend` subclass keeps its name, its methods,
and its import path. Existing user code that subclasses it must keep working.

2. **Implementation moves to capabilities.** The legacy frontend delegates to capability objects
internally. This avoids duplicating logic in both old and new code paths.

3. **`MachineBackend` and `DeviceBackend` are independent hierarchies.** They are structurally
similar but intentionally separate. Legacy backends never inherit from `DeviceBackend`.

4. **Always use adapters.** Even when the old and new backend signatures happen to match today,
use an adapter. This protects against silent breakage if the new capability backend changes
later. The adapter is the single point where old meets new.

### Adapter pattern

Every legacy frontend that delegates to a capability needs an adapter. The adapter:
- Implements the new capability backend interface (`DeviceBackend` subclass)
- Wraps a legacy backend instance and delegates to it
- Translates between old and new signatures if they differ
- Has no-op `setup()`/`stop()` since lifecycle is managed by the legacy `Machine`

```python
# In the legacy frontend module (e.g. pylabrobot/legacy/shaking/shaker.py)

from pylabrobot.capabilities.shaking import ShakerBackend as _NewShakerBackend, ShakingCapability

class _ShakingAdapter(_NewShakerBackend):
"""Adapts a legacy ShakerBackend to the new ShakerBackend interface."""
def __init__(self, legacy: ShakerBackend):
self._legacy = legacy
async def setup(self): pass
async def stop(self): pass
async def start_shaking(self, speed: float):
await self._legacy.start_shaking(speed)
async def stop_shaking(self):
await self._legacy.stop_shaking()
@property
def supports_locking(self) -> bool:
return self._legacy.supports_locking
async def lock_plate(self):
await self._legacy.lock_plate()
async def unlock_plate(self):
await self._legacy.unlock_plate()

class Shaker(Machine):
def __init__(self, backend: ShakerBackend): # legacy ShakerBackend
super().__init__(backend=backend)
self._cap = ShakingCapability(backend=_ShakingAdapter(backend))

async def shake(self, speed, duration=None):
await self._cap.shake(speed=speed, duration=duration)
```

### One-to-many split (e.g. PlateReader)

When the old backend is a "god object" that gets split into multiple capabilities:

```
Old: PlateReaderBackend(MachineBackend)
read_absorbance(), read_fluorescence(), read_luminescence(), open(), close()

New: AbsorbanceBackend(DeviceBackend) with read_absorbance()
FluorescenceBackend(DeviceBackend) with read_fluorescence()
LuminescenceBackend(DeviceBackend) with read_luminescence()
```

The old `PlateReaderBackend` has `read_absorbance()` but is not an `AbsorbanceBackend`. You can't
pass it directly to `AbsorbanceCapability`. Use **adapters** in the legacy frontend:

```python
# pylabrobot/legacy/plate_reading/plate_reader.py

class _AbsorbanceAdapter(AbsorbanceBackend):
"""Adapts a legacy PlateReaderBackend to the AbsorbanceBackend interface."""
def __init__(self, legacy: PlateReaderBackend):
self._legacy = legacy

async def setup(self): pass # lifecycle managed by the legacy Machine
async def stop(self): pass

async def read_absorbance(self, plate, wells, wavelength):
# translate between old and new signatures if needed
return await self._legacy.read_absorbance(plate, wells, wavelength)


class PlateReader(Machine):
def __init__(self, backend: PlateReaderBackend):
super().__init__(backend=backend)
self._absorbance = AbsorbanceCapability(backend=_AbsorbanceAdapter(backend))
self._fluorescence = FluorescenceCapability(backend=_FluorescenceAdapter(backend))
self._luminescence = LuminescenceCapability(backend=_LuminescenceAdapter(backend))
```

Adapters belong in the legacy layer. They are the only place that knows about both the old and
new interfaces. If the new backend signature changes later, you update the adapter — the old
`PlateReaderBackend` interface is unaffected.

### Case 3: Signature mismatch

When the old and new backends have the same method name but different signatures:

```
Old: read_absorbance(plate, wells, wavelength) -> List[Dict]
New: read_absorbance(plate, wells, wavelength) -> List[AbsorbanceResult]
```

This is handled the same way as Case 2 — the adapter translates:

```python
class _AbsorbanceAdapter(AbsorbanceBackend):
async def read_absorbance(self, plate, wells, wavelength) -> List[AbsorbanceResult]:
dicts = await self._legacy.read_absorbance(plate, wells, wavelength)
return [AbsorbanceResult(data=d["data"], wavelength=wavelength, ...) for d in dicts]
```

### Summary

| Situation | Fix |
|-----------|-----|
| 1:1 mapping, same signatures | Adapter in legacy frontend (protects against future divergence) |
| 1:N split | Adapter per capability in the legacy frontend |
| Signature mismatch | Adapter that translates between old and new signatures |

In all cases, the adapter lives in the legacy layer and is the only code that knows about both
the old and new interfaces.
10 changes: 5 additions & 5 deletions docs/user_guide/_getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ Different machines use different communication modes. Replace `[usb]` with one o
| `hid` | hid | HID devices: e.g. Inheco Incubator/Shaker (HID mode) |
| `modbus` | pymodbus | Modbus devices: e.g. Agrow Pump Array |
| `opentrons` | opentrons-http-api-client | e.g. Opentrons backend |
| `microscopy` | numpy (1.26), opencv-python | e.g. Cytation imager |
| `cytation-microscopy` | numpy (1.26), opencv-python | Cytation imager |
| `sila` | zeroconf, grpcio | SiLA devices |
| `pico` | microscopy + sila | ImageXpress Pico microscope |
| `pico` | opencv-python, numpy, sila | ImageXpress Pico microscope |
| `dev` | All of the above + testing/linting tools | Development |

Or install all dependencies:
Expand All @@ -60,7 +60,7 @@ Or install all dependencies:
pip install 'pylabrobot[all]'
```

Microscopy is not included in the `all` group because it requires an older version of numpy. If you want to use microscopy features, you need to install those dependencies separately through `pip install "pylabrobot[microscopy]"`.
Cytation microscopy is not included in the `all` group because it requires an older version of numpy. If you want to use Cytation imaging features, install those dependencies separately through `pip install "pylabrobot[cytation-microscopy]"`.

### From source

Expand Down Expand Up @@ -177,6 +177,6 @@ In order to use imaging on the Cytation, you need to:

1. Install python 3.10
2. Download Spinnaker SDK and install (including Python) [https://www.teledynevisionsolutions.com/products/spinnaker-sdk/](https://www.teledynevisionsolutions.com/products/spinnaker-sdk/)
3. Install numpy==1.26 (this is an older version)
3. Install the cytation-microscopy dependencies: `pip install "pylabrobot[cytation-microscopy]"`

If you just want to do plate reading, heating, shaknig, etc. you don't need to follow these specific steps.
If you just want to do plate reading, heating, shaking, etc. you don't need to follow these specific steps.
10 changes: 10 additions & 0 deletions pylabrobot/agilent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from .biotek import (
BioTekBackend,
Cytation1,
Cytation5,
Cytation5ImagingConfig,
CytationBackend,
CytationImagingConfig,
SynergyH1,
SynergyH1Backend,
)
9 changes: 9 additions & 0 deletions pylabrobot/agilent/biotek/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from .biotek import BioTekBackend
from .cytation import (
Cytation1,
Cytation5,
Cytation5ImagingConfig,
CytationBackend,
CytationImagingConfig,
)
from .synergy_h1 import SynergyH1, SynergyH1Backend
Loading
Loading